@agent-team-foundation/first-tree-hub 0.10.7 → 0.10.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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-DPyf745N-BSc8QNcR.mjs";
3
3
  import { C as serverConfigSchema, S as resolveConfigReadonly, _ as loadAgents, b as resetConfig, c as saveCredentials, 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, x as resetConfigMeta } from "./bootstrap-jx5nN1qZ.mjs";
4
- import { $ as refreshTokenSchema, A as createOrgFromMeSchema, B as imageInlineContentSchema, C as clientRegisterSchema, D as createAgentSchema, E as createAdapterMappingSchema, F as dryRunAgentRuntimeConfigSchema, G as isReservedAgentName$1, H as inboxDeliverFrameSchema$1, I as extractMentions, J as loginSchema, K as joinByInvitationSchema, L as githubCallbackQuerySchema, M as createTaskSchema, N as defaultRuntimeConfigPayload, O as createChatSchema, P as delegateFeishuUserSchema, Q as rebindAgentSchema, R as githubDevCallbackQuerySchema, S as clientCapabilitiesSchema$1, St as wsAuthFrameSchema, T as createAdapterConfigSchema, U as inboxPollQuerySchema, V as inboxAckFrameSchema, W as isRedactedEnvValue, X as notificationQuerySchema, Y as messageSourceSchema$1, Z as paginationQuerySchema, _ as adminUpdateTaskSchema, _t as updateClientCapabilitiesSchema, a as AGENT_STATUSES, at as sendToAgentSchema, b as agentRuntimeConfigPayloadSchema$1, bt as updateSystemConfigSchema, ct as sessionEventSchema$1, d as TASK_HEALTH_SIGNALS, dt as switchOrgSchema, et as runtimeStateMessageSchema, f as TASK_STATUSES, ft as taskListQuerySchema, g as adminCreateTaskSchema, gt as updateChatSchema, h as addParticipantSchema, ht as updateAgentSchema, i as AGENT_SOURCES, it as sendMessageSchema, j as createOrganizationSchema, k as createMemberSchema, l as SYSTEM_CONFIG_DEFAULTS, lt as sessionReconcileRequestSchema, m as WS_AUTH_FRAME_TIMEOUT_MS, mt as updateAgentRuntimeConfigSchema, n as AGENT_NAME_REGEX$1, nt as scanMentionTokens, o as AGENT_TYPES, ot as sessionCompletionMessageSchema, p as TASK_TERMINAL_STATUSES, pt as updateAdapterConfigSchema, q as linkTaskChatSchema, r as AGENT_SELECTOR_HEADER$1, rt as selfServiceFeishuBotSchema, s as AGENT_VISIBILITY, st as sessionEventMessageSchema, t as AGENT_BIND_REJECT_REASONS, tt as safeRedirectPath, u as TASK_CREATOR_TYPES, ut as sessionStateMessageSchema, v as agentBindRequestSchema, vt as updateMemberSchema, w as connectTokenExchangeSchema, x as agentTypeSchema$1, xt as updateTaskStatusSchema, y as agentPinnedMessageSchema$1, yt as updateOrganizationSchema, z as githubStartQuerySchema } from "./dist-CbX9mUVH.mjs";
5
- import { a as ForbiddenError, c as buildInviteUrl, d as getActiveInvitation, f as invitationRedemptions, g as recordRedemption, i as ConflictError, l as ensureActiveInvitation, m as organizations, n as BadRequestError, o as NotFoundError, p as invitations, r as ClientOrgMismatchError$1, s as UnauthorizedError, t as AppError, u as findActiveByToken, v as users, y as uuidv7 } from "./invitation-BljIolbO-DLeHfURd.mjs";
4
+ import { $ as refreshTokenSchema, A as createOrgFromMeSchema, B as imageInlineContentSchema, C as clientRegisterSchema, D as createAgentSchema, E as createAdapterMappingSchema, F as dryRunAgentRuntimeConfigSchema, G as isReservedAgentName$1, H as inboxDeliverFrameSchema$1, I as extractMentions, J as loginSchema, K as joinByInvitationSchema, L as githubCallbackQuerySchema, M as createTaskSchema, N as defaultRuntimeConfigPayload, O as createChatSchema, P as delegateFeishuUserSchema, Q as rebindAgentSchema, R as githubDevCallbackQuerySchema, S as clientCapabilitiesSchema$1, St as wsAuthFrameSchema, T as createAdapterConfigSchema, U as inboxPollQuerySchema, V as inboxAckFrameSchema, W as isRedactedEnvValue, X as notificationQuerySchema, Y as messageSourceSchema$1, Z as paginationQuerySchema, _ as adminUpdateTaskSchema, _t as updateClientCapabilitiesSchema, a as AGENT_STATUSES, at as sendToAgentSchema, b as agentRuntimeConfigPayloadSchema$1, bt as updateSystemConfigSchema, ct as sessionEventSchema$1, d as TASK_HEALTH_SIGNALS, dt as switchOrgSchema, et as runtimeStateMessageSchema, f as TASK_STATUSES, ft as taskListQuerySchema, g as adminCreateTaskSchema, gt as updateChatSchema, h as addParticipantSchema, ht as updateAgentSchema, i as AGENT_SOURCES, it as sendMessageSchema, j as createOrganizationSchema, k as createMemberSchema, l as SYSTEM_CONFIG_DEFAULTS, lt as sessionReconcileRequestSchema, m as WS_AUTH_FRAME_TIMEOUT_MS, mt as updateAgentRuntimeConfigSchema, n as AGENT_NAME_REGEX$1, nt as scanMentionTokens, o as AGENT_TYPES, ot as sessionCompletionMessageSchema, p as TASK_TERMINAL_STATUSES, pt as updateAdapterConfigSchema, q as linkTaskChatSchema, r as AGENT_SELECTOR_HEADER$1, rt as selfServiceFeishuBotSchema, s as AGENT_VISIBILITY, st as sessionEventMessageSchema, t as AGENT_BIND_REJECT_REASONS, tt as safeRedirectPath, u as TASK_CREATOR_TYPES, ut as sessionStateMessageSchema, v as agentBindRequestSchema, vt as updateMemberSchema, w as connectTokenExchangeSchema, x as agentTypeSchema$1, xt as updateTaskStatusSchema, y as agentPinnedMessageSchema$1, yt as updateOrganizationSchema, z as githubStartQuerySchema } from "./dist-DSr_I5Ia.mjs";
5
+ import { _ as recordRedemption, a as ConflictError, b as uuidv7, c as UnauthorizedError, d as findActiveByToken, f as getActiveInvitation, h as organizations, i as ClientUserMismatchError$1, l as buildInviteUrl, m as invitations, n as BadRequestError, o as ForbiddenError, p as invitationRedemptions, r as ClientOrgMismatchError$1, s as NotFoundError, t as AppError, u as ensureActiveInvitation, y as users } from "./invitation-B1pjAyOz-BaCA9PII.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";
@@ -19,11 +19,11 @@ import { execFileSync, execSync, spawn, spawnSync } from "node:child_process";
19
19
  import { Codex } from "@openai/codex-sdk";
20
20
  import { fileURLToPath } from "node:url";
21
21
  import * as semver from "semver";
22
+ import { confirm, input, password, select } from "@inquirer/prompts";
22
23
  import bcrypt from "bcrypt";
23
24
  import { and, asc, count, desc, eq, gt, inArray, isNotNull, isNull, lt, ne, or, sql } from "drizzle-orm";
24
25
  import { drizzle } from "drizzle-orm/postgres-js";
25
26
  import postgres from "postgres";
26
- import { confirm, input, password, select } from "@inquirer/prompts";
27
27
  import { migrate } from "drizzle-orm/postgres-js/migrator";
28
28
  import { bigserial, boolean, index, integer, jsonb, pgTable, primaryKey, serial, text, timestamp, unique, uniqueIndex } from "drizzle-orm/pg-core";
29
29
  import cors from "@fastify/cors";
@@ -770,6 +770,11 @@ z.object({
770
770
  sdkVersion: z.string().max(50).optional(),
771
771
  wireCapabilities: clientWireCapabilitiesSchema.optional()
772
772
  });
773
+ z.object({
774
+ clientId: z.string(),
775
+ previousUserId: z.string().nullable(),
776
+ unpinnedAgentCount: z.number().int().nonnegative()
777
+ });
773
778
  const capabilityStateSchema = z.enum([
774
779
  "ok",
775
780
  "missing",
@@ -1037,6 +1042,13 @@ z.object({
1037
1042
  displayName: z.string().min(1).max(200)
1038
1043
  });
1039
1044
  z.object({ organizationId: z.string().min(1) });
1045
+ z.object({
1046
+ id: z.string(),
1047
+ organizationId: z.string(),
1048
+ organizationName: z.string(),
1049
+ role: z.enum(["admin", "member"]),
1050
+ agentId: z.string()
1051
+ });
1040
1052
  const memberRoleSchema = z.enum(["admin", "member"]);
1041
1053
  const memberSchema = z.object({
1042
1054
  id: z.string(),
@@ -1761,8 +1773,8 @@ var SdkError = class extends Error {
1761
1773
  /**
1762
1774
  * Thrown (emitted on `error` and rejected from `connect()`) when the server
1763
1775
  * refuses a `client:register` because the local clientId is bound to a
1764
- * different organization. The CLI layer detects this via `instanceof` and
1765
- * prompts the user before rotating the local clientId.
1776
+ * different organization. Retained for wire compatibility the read paths
1777
+ * that produced this code were retired in decouple-client-from-identity §4.1.
1766
1778
  */
1767
1779
  var ClientOrgMismatchError = class extends Error {
1768
1780
  code = "CLIENT_ORG_MISMATCH";
@@ -1771,6 +1783,21 @@ var ClientOrgMismatchError = class extends Error {
1771
1783
  this.name = "ClientOrgMismatchError";
1772
1784
  }
1773
1785
  };
1786
+ /**
1787
+ * Thrown when the server refuses `client:register` because the local
1788
+ * client.yaml is owned by a different user. The CLI detects this via
1789
+ * `instanceof` and guides the operator to run
1790
+ * `first-tree-hub client claim --confirm` to take ownership (which unpins
1791
+ * the previous owner's agents from this machine). See decouple-client-from-
1792
+ * identity §4.4.
1793
+ */
1794
+ var ClientUserMismatchError = class extends Error {
1795
+ code = "CLIENT_USER_MISMATCH";
1796
+ constructor(message = "Client belongs to a different user") {
1797
+ super(message);
1798
+ this.name = "ClientUserMismatchError";
1799
+ }
1800
+ };
1774
1801
  const RECONNECT_BASE_MS = 1e3;
1775
1802
  const RECONNECT_MAX_MS = 3e4;
1776
1803
  const WS_CONNECT_TIMEOUT_MS = 1e4;
@@ -2121,7 +2148,7 @@ var ClientConnection = class extends EventEmitter {
2121
2148
  const code = typeof msg.code === "string" ? msg.code : void 0;
2122
2149
  const message = typeof msg.message === "string" ? msg.message : "unknown";
2123
2150
  this.closing = true;
2124
- const err = code === "CLIENT_ORG_MISMATCH" ? new ClientOrgMismatchError(message) : /* @__PURE__ */ new Error(`client:register rejected: ${message}`);
2151
+ const err = code === "CLIENT_USER_MISMATCH" ? new ClientUserMismatchError(message) : code === "CLIENT_ORG_MISMATCH" ? new ClientOrgMismatchError(message) : /* @__PURE__ */ new Error(`client:register rejected: ${message}`);
2125
2152
  this.lastHandshakeError = err;
2126
2153
  this.wsLogger.error({
2127
2154
  code,
@@ -5169,6 +5196,12 @@ var AgentSlot = class {
5169
5196
  agentId: config.agentId
5170
5197
  });
5171
5198
  }
5199
+ get name() {
5200
+ return this.config.name;
5201
+ }
5202
+ get agentId() {
5203
+ return this.config.agentId;
5204
+ }
5172
5205
  get clientConnection() {
5173
5206
  return this.config.clientConnection;
5174
5207
  }
@@ -5971,6 +6004,130 @@ function makeUuidV7() {
5971
6004
  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
5972
6005
  }
5973
6006
  //#endregion
6007
+ //#region src/core/agent-prune.ts
6008
+ const minimalAgentYamlSchema = z.object({ agentId: z.string().min(1) }).passthrough();
6009
+ /**
6010
+ * Cross-reference local `agents/<name>/agent.yaml` files against the
6011
+ * server's pinned-agent set, returning every alias that won't bind on
6012
+ * THIS client.
6013
+ *
6014
+ * Why we don't use `loadAgents`:
6015
+ * `shared/config/loader.loadAgents` is fail-fast — one malformed
6016
+ * agent.yaml throws and the whole scan dies. The dominant prune target
6017
+ * IS the malformed dir (typo `agent add d`, half-written yaml, missing
6018
+ * agentId), so we walk dirs ourselves and degrade per-entry instead.
6019
+ *
6020
+ * Why we filter by clientId, not just userId:
6021
+ * `listPinnedAgents` (`/api/v1/clients/me/agents`) returns every agent
6022
+ * pinned to ANY client this user owns (cross-machine). For prune the
6023
+ * relevant question is "will R-RUN accept it on THIS machine", which
6024
+ * needs `agents.client_id === current client.id`. Anything pinned on
6025
+ * another client is reported with `pinned-elsewhere` so the operator
6026
+ * can either re-pin or delete the local alias deliberately.
6027
+ */
6028
+ async function findStaleAliases(opts) {
6029
+ const agentsDir = opts.agentsDir ?? join(DEFAULT_CONFIG_DIR, "agents");
6030
+ if (!existsSync(agentsDir)) return [];
6031
+ const remote = await opts.listPinnedAgents();
6032
+ const pinnedHere = /* @__PURE__ */ new Set();
6033
+ const pinnedElsewhere = /* @__PURE__ */ new Map();
6034
+ for (const r of remote) if (r.clientId === opts.clientId) pinnedHere.add(r.agentId);
6035
+ else pinnedElsewhere.set(r.agentId, r.clientId);
6036
+ const stale = [];
6037
+ for (const entry of readdirSync(agentsDir)) {
6038
+ const agentDir = join(agentsDir, entry);
6039
+ let isDir = false;
6040
+ try {
6041
+ isDir = statSync(agentDir).isDirectory();
6042
+ } catch {
6043
+ continue;
6044
+ }
6045
+ if (!isDir) continue;
6046
+ const yamlPath = join(agentDir, "agent.yaml");
6047
+ if (!existsSync(yamlPath)) {
6048
+ stale.push({
6049
+ name: entry,
6050
+ agentId: null,
6051
+ reason: {
6052
+ kind: "unreadable",
6053
+ error: "missing agent.yaml"
6054
+ }
6055
+ });
6056
+ continue;
6057
+ }
6058
+ let agentId;
6059
+ try {
6060
+ const raw = parse(readFileSync(yamlPath, "utf-8"));
6061
+ const parsed = minimalAgentYamlSchema.safeParse(raw);
6062
+ if (!parsed.success) {
6063
+ const issue = parsed.error.issues[0]?.message ?? "schema error";
6064
+ stale.push({
6065
+ name: entry,
6066
+ agentId: null,
6067
+ reason: {
6068
+ kind: "unreadable",
6069
+ error: issue
6070
+ }
6071
+ });
6072
+ continue;
6073
+ }
6074
+ agentId = parsed.data.agentId;
6075
+ } catch (err) {
6076
+ const msg = err instanceof Error ? err.message : String(err);
6077
+ stale.push({
6078
+ name: entry,
6079
+ agentId: null,
6080
+ reason: {
6081
+ kind: "unreadable",
6082
+ error: msg
6083
+ }
6084
+ });
6085
+ continue;
6086
+ }
6087
+ if (pinnedHere.has(agentId)) continue;
6088
+ const otherClient = pinnedElsewhere.get(agentId);
6089
+ if (otherClient !== void 0) stale.push({
6090
+ name: entry,
6091
+ agentId,
6092
+ reason: {
6093
+ kind: "pinned-elsewhere",
6094
+ clientId: otherClient
6095
+ }
6096
+ });
6097
+ else stale.push({
6098
+ name: entry,
6099
+ agentId,
6100
+ reason: { kind: "unowned" }
6101
+ });
6102
+ }
6103
+ return stale;
6104
+ }
6105
+ /** Human-readable suffix for the per-alias listing. */
6106
+ function formatStaleReason(reason) {
6107
+ switch (reason.kind) {
6108
+ case "unreadable": return `unreadable: ${reason.error}`;
6109
+ case "unowned": return "no longer owned by you (deleted or transferred)";
6110
+ case "pinned-elsewhere": return `pinned to another client: ${reason.clientId}`;
6111
+ }
6112
+ }
6113
+ /**
6114
+ * Remove an agent's local footprint: the YAML alias dir, the workspace
6115
+ * tree under `data/workspaces/<name>`, and the session-mapping file under
6116
+ * `data/sessions/<name>.json`. Mirrors what `agent remove` does, exposed
6117
+ * separately so prune and claim can share it.
6118
+ */
6119
+ function removeLocalAgent(name) {
6120
+ rmSync(join(DEFAULT_CONFIG_DIR, "agents", name), {
6121
+ recursive: true,
6122
+ force: true
6123
+ });
6124
+ rmSync(join(DEFAULT_DATA_DIR$1, "workspaces", name), {
6125
+ recursive: true,
6126
+ force: true
6127
+ });
6128
+ rmSync(join(DEFAULT_DATA_DIR$1, "sessions", `${name}.json`), { force: true });
6129
+ }
6130
+ //#endregion
5974
6131
  //#region src/core/client-reidentify.ts
5975
6132
  /**
5976
6133
  * Handle a `CLIENT_ORG_MISMATCH` from the server by rotating the local
@@ -7063,6 +7220,69 @@ function checkAgentConfigs() {
7063
7220
  };
7064
7221
  }
7065
7222
  }
7223
+ /**
7224
+ * Server-aware agent reconciliation. Walks `agents/<name>/agent.yaml` and
7225
+ * cross-references each `agentId` with `/api/v1/clients/me/agents`,
7226
+ * filtering by `clientId` so the "stale" verdict matches what R-RUN will
7227
+ * actually accept on this machine.
7228
+ *
7229
+ * Categorises each local alias into:
7230
+ * - pinned — bind would succeed on this client.
7231
+ * - pinned-elsewhere — owned by you, but pinned to a different client
7232
+ * (alias is dead weight here; real agent is alive
7233
+ * on the other machine).
7234
+ * - unowned — agentId not in the server's response at all.
7235
+ * - unreadable — yaml missing/malformed/no agentId.
7236
+ *
7237
+ * The plain `checkAgentConfigs` (sync, local-only) is retained for
7238
+ * back-compat with external consumers but its "N configured" wording is
7239
+ * misleading because stale aliases never bind at runtime.
7240
+ *
7241
+ * Skipped reconciliation (server unreachable / unauthenticated) returns
7242
+ * `ok: false` — doctor's other server-touching checks already report
7243
+ * connectivity loss as a failure, and silently passing here would hide
7244
+ * the very issue the operator is running doctor to diagnose.
7245
+ */
7246
+ async function reconcileAgentConfigs(opts) {
7247
+ const agentsDir = opts.agentsDir ?? join(DEFAULT_CONFIG_DIR, "agents");
7248
+ let localCount = 0;
7249
+ if (existsSync(agentsDir)) for (const entry of readdirSync(agentsDir)) try {
7250
+ if (statSync(join(agentsDir, entry)).isDirectory()) localCount++;
7251
+ } catch {}
7252
+ if (localCount === 0) return {
7253
+ label: "Agents",
7254
+ ok: false,
7255
+ detail: "no agents configured"
7256
+ };
7257
+ let stale;
7258
+ try {
7259
+ stale = await findStaleAliases({
7260
+ clientId: opts.clientId,
7261
+ listPinnedAgents: opts.listPinnedAgents,
7262
+ agentsDir
7263
+ });
7264
+ } catch (err) {
7265
+ const msg = err instanceof Error ? err.message : String(err);
7266
+ return {
7267
+ label: "Agents",
7268
+ ok: false,
7269
+ detail: `${localCount} configured locally — server reconciliation failed (${msg.slice(0, 60)})`
7270
+ };
7271
+ }
7272
+ const pinnedCount = localCount - stale.length;
7273
+ if (stale.length === 0) return {
7274
+ label: "Agents",
7275
+ ok: true,
7276
+ detail: `${localCount} configured, all pinned to this client`
7277
+ };
7278
+ const staleSummary = stale.map((s) => `${s.name} [${formatStaleReason(s.reason)}]`).slice(0, 5).join("; ");
7279
+ const truncated = stale.length > 5 ? `; ...+${stale.length - 5} more` : "";
7280
+ return {
7281
+ label: "Agents",
7282
+ ok: false,
7283
+ detail: `${localCount} configured locally, ${pinnedCount} pinned to this client; ${stale.length} stale: ${staleSummary}${truncated} — run \`first-tree-hub agent prune\` to clean up`
7284
+ };
7285
+ }
7066
7286
  function checkBackgroundService() {
7067
7287
  const info = getClientServiceStatus();
7068
7288
  if (info.platform === "unsupported") return {
@@ -7556,7 +7776,7 @@ async function onboardCreate(args) {
7556
7776
  }
7557
7777
  const runtimeAgent = args.type === "human" ? args.assistant : args.id;
7558
7778
  if (args.feishuBotAppId && args.feishuBotAppSecret) {
7559
- const { bindFeishuBot } = await import("./feishu-DvjRZMdZ.mjs").then((n) => n.r);
7779
+ const { bindFeishuBot } = await import("./feishu-eynC54km.mjs").then((n) => n.r);
7560
7780
  const targetAgentUuid = args.type === "human" ? assistantUuid : primary.uuid;
7561
7781
  if (!targetAgentUuid) print.line(`Warning: Cannot bind Feishu bot — no runtime agent available for "${args.id}".\n`);
7562
7782
  else {
@@ -8536,7 +8756,7 @@ function createFeedbackHandler(config) {
8536
8756
  return { handle };
8537
8757
  }
8538
8758
  //#endregion
8539
- //#region ../server/dist/app-Ed9CsDC-.mjs
8759
+ //#region ../server/dist/app-XRPDwwkj.mjs
8540
8760
  var __defProp = Object.defineProperty;
8541
8761
  var __exportAll = (all, no_symbols) => {
8542
8762
  let target = {};
@@ -8631,6 +8851,20 @@ const chatParticipants = pgTable("chat_participants", {
8631
8851
  mode: text("mode").notNull().default("full"),
8632
8852
  joinedAt: timestamp("joined_at", { withTimezone: true }).notNull().defaultNow()
8633
8853
  }, (table) => [primaryKey({ columns: [table.chatId, table.agentId] }), index("idx_participants_agent").on(table.agentId)]);
8854
+ /** Organization membership. Links a user to an org with a role and a 1:1 human agent. */
8855
+ const members = pgTable("members", {
8856
+ id: text("id").primaryKey(),
8857
+ userId: text("user_id").notNull().references(() => users.id),
8858
+ organizationId: text("organization_id").notNull().references(() => organizations.id),
8859
+ agentId: text("agent_id").unique().notNull().references(() => agents.uuid),
8860
+ role: text("role").notNull(),
8861
+ status: text("status").notNull().default("active"),
8862
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
8863
+ }, (table) => [
8864
+ unique("uq_members_user_org").on(table.userId, table.organizationId),
8865
+ index("idx_members_user").on(table.userId),
8866
+ index("idx_members_org").on(table.organizationId)
8867
+ ]);
8634
8868
  function requireAgent(request) {
8635
8869
  const agent = request.agent;
8636
8870
  if (!agent) throw new UnauthorizedError("Agent authentication required");
@@ -8675,18 +8909,41 @@ function memberScope(request) {
8675
8909
  /**
8676
8910
  * SQL WHERE conditions for agents visible to a member.
8677
8911
  * Visibility is the same for all roles:
8678
- * same org + not deleted + (organization-visible OR managerId = self)
8912
+ * target org + not deleted + (organization-visible OR managerId = caller's member)
8913
+ *
8914
+ * Takes explicit `orgId` + `memberId` rather than reading them from
8915
+ * MemberScope: an admin viewing a non-default org passes
8916
+ * `requireMemberInOrg(db, request, orgId).memberId` to derive the right
8917
+ * memberId for that org (decouple-client-from-identity §4.5.1).
8679
8918
  */
8680
- function agentVisibilityCondition(scope) {
8681
- 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)));
8919
+ function agentVisibilityCondition(orgId, memberId) {
8920
+ return and(eq(agents.organizationId, orgId), ne(agents.status, AGENT_STATUSES.DELETED), or(eq(agents.visibility, AGENT_VISIBILITY.ORGANIZATION), eq(agents.managerId, memberId)));
8682
8921
  }
8683
8922
  /**
8684
- * Assert a single agent is visible to the member.
8685
- * Single query — returns 404 for both "not found" and "not visible"
8686
- * to prevent UUID enumeration.
8923
+ * Assert a single agent is visible to the caller.
8924
+ *
8925
+ * The agent is identified by UUID, so its organization is *intrinsic to the
8926
+ * row itself* — we don't gate on the JWT default org. Instead we resolve the
8927
+ * caller's active membership in `agent.organizationId` and reuse the same
8928
+ * visibility rule (organization-visible OR managerId = caller's member in
8929
+ * that org). This lets a multi-org user hit `/admin/agents/:uuid` for an
8930
+ * agent in a non-default org without re-issuing the JWT — `/auth/switch-org`
8931
+ * now returns 204 only and the web client carries the selected org in
8932
+ * localStorage (decouple-client-from-identity §C.2 / §D fix).
8933
+ *
8934
+ * Returns 404 for both "not found" and "not visible" to prevent UUID
8935
+ * enumeration.
8687
8936
  */
8688
8937
  async function assertAgentVisible(db, scope, agentUuid) {
8689
- const [row] = await db.select({ uuid: agents.uuid }).from(agents).where(and(eq(agents.uuid, agentUuid), agentVisibilityCondition(scope))).limit(1);
8938
+ const [agent] = await db.select({
8939
+ uuid: agents.uuid,
8940
+ organizationId: agents.organizationId,
8941
+ status: agents.status
8942
+ }).from(agents).where(eq(agents.uuid, agentUuid)).limit(1);
8943
+ if (!agent || agent.status === AGENT_STATUSES.DELETED) throw new NotFoundError(`Agent "${agentUuid}" not found`);
8944
+ const [member] = await db.select({ id: members.id }).from(members).where(and(eq(members.userId, scope.userId), eq(members.organizationId, agent.organizationId), eq(members.status, "active"))).limit(1);
8945
+ if (!member) throw new NotFoundError(`Agent "${agentUuid}" not found`);
8946
+ const [row] = await db.select({ uuid: agents.uuid }).from(agents).where(and(eq(agents.uuid, agentUuid), agentVisibilityCondition(agent.organizationId, member.id))).limit(1);
8690
8947
  if (!row) throw new NotFoundError(`Agent "${agentUuid}" not found`);
8691
8948
  }
8692
8949
  /**
@@ -8708,9 +8965,22 @@ async function assertChatAccess(db, scope, chatId) {
8708
8965
  if (!managed) throw new NotFoundError(`Chat "${chatId}" not found`);
8709
8966
  }
8710
8967
  /**
8711
- * Assert the member can manage (update/delete/token/suspend) an agent.
8712
- * Admin can manage all agents in their org. Non-admin can only manage agents where managerId = self.
8713
- * Always verifies the agent exists and belongs to the same org (throws 404 if not).
8968
+ * Assert the caller can manage (update/delete/token/suspend) an agent.
8969
+ *
8970
+ * Manageability is anchored on the agent's *own* organization, not on the
8971
+ * JWT default org. We resolve the caller's active membership in
8972
+ * `agent.organizationId` and grant manage access if either:
8973
+ * - that membership is `admin` (admin in the agent's org), or
8974
+ * - the agent's `managerId` equals the caller's memberId *in that org*.
8975
+ *
8976
+ * This is the cross-org switch-org fix: with `/auth/switch-org` now 204 the
8977
+ * scope.organizationId / scope.memberId are JWT defaults, so the previous
8978
+ * `agent.organizationId !== scope.organizationId` short-circuit was 404'ing
8979
+ * every `:uuid` route as soon as the user looked at a non-default org. We
8980
+ * authorize against the agent's actual org instead.
8981
+ *
8982
+ * Returns 404 for "not found", "not a member of agent's org", or "not
8983
+ * authorized" — same shape as before, to prevent UUID enumeration.
8714
8984
  */
8715
8985
  async function assertCanManage(db, scope, agentUuid) {
8716
8986
  const [agent] = await db.select({
@@ -8718,9 +8988,85 @@ async function assertCanManage(db, scope, agentUuid) {
8718
8988
  managerId: agents.managerId,
8719
8989
  organizationId: agents.organizationId
8720
8990
  }).from(agents).where(and(eq(agents.uuid, agentUuid), ne(agents.status, AGENT_STATUSES.DELETED))).limit(1);
8721
- if (!agent || agent.organizationId !== scope.organizationId) throw new NotFoundError(`Agent "${agentUuid}" not found`);
8722
- if (scope.role === "admin") return;
8723
- if (agent.managerId !== scope.memberId) throw new NotFoundError(`Agent "${agentUuid}" not found`);
8991
+ if (!agent) throw new NotFoundError(`Agent "${agentUuid}" not found`);
8992
+ const [memberRow] = await db.select({
8993
+ id: members.id,
8994
+ role: members.role
8995
+ }).from(members).where(and(eq(members.userId, scope.userId), eq(members.organizationId, agent.organizationId), eq(members.status, "active"))).limit(1);
8996
+ if (!memberRow) throw new NotFoundError(`Agent "${agentUuid}" not found`);
8997
+ if (memberRow.role === "admin") return;
8998
+ if (agent.managerId === memberRow.id) return;
8999
+ throw new NotFoundError(`Agent "${agentUuid}" not found`);
9000
+ }
9001
+ /**
9002
+ * Assert the request's authenticated user has an active membership in
9003
+ * `orgId` and return its `(memberId, role)`. Used by admin routes that
9004
+ * accept an explicit `organizationId` from the request (query / body /
9005
+ * path) and must verify role realtime — JWT `organizationId` and `role`
9006
+ * claims are hints, not authoritative (decouple-client-from-identity §4.5).
9007
+ *
9008
+ * Throws {@link ForbiddenError} if the user is not an active member of the
9009
+ * target org.
9010
+ */
9011
+ async function requireMemberInOrg(db, request, orgId) {
9012
+ const m = requireMember(request);
9013
+ const [row] = await db.select({
9014
+ id: members.id,
9015
+ role: members.role
9016
+ }).from(members).where(and(eq(members.userId, m.userId), eq(members.organizationId, orgId), eq(members.status, "active"))).limit(1);
9017
+ if (!row) throw new ForbiddenError("Not an active member of the target organization");
9018
+ return {
9019
+ memberId: row.id,
9020
+ role: row.role
9021
+ };
9022
+ }
9023
+ /**
9024
+ * Resolve the `(organizationId, memberId, role)` an admin route should
9025
+ * operate against, based on the unified PR-D scoping rule:
9026
+ * - If the request supplies `?organizationId=…` (query) or it appears in
9027
+ * the body, verify the caller is an active member there and return
9028
+ * that membership realtime.
9029
+ * - Otherwise fall back to the JWT default org (the existing
9030
+ * `MemberScope` from `memberScope(request)`).
9031
+ *
9032
+ * All admin listing routes call this so that the cross-org switch — driven
9033
+ * entirely client-side via `localStorage.selectedOrganizationId` after
9034
+ * `/auth/switch-org` returns 204 — funnels through one consistent gate
9035
+ * (decouple-client-from-identity §4.5 / §D, codex P1 #2 fix).
9036
+ *
9037
+ * @returns the effective scope for the rest of the route to read from.
9038
+ */
9039
+ async function resolveAdminScope(db, request, scope, requestedOrganizationId) {
9040
+ if (!requestedOrganizationId || requestedOrganizationId === scope.organizationId) return scope;
9041
+ const probe = await requireMemberInOrg(db, request, requestedOrganizationId);
9042
+ return {
9043
+ ...scope,
9044
+ memberId: probe.memberId,
9045
+ organizationId: requestedOrganizationId,
9046
+ role: probe.role
9047
+ };
9048
+ }
9049
+ /**
9050
+ * Cross-org listing helper for "agents I personally manage". Used by the
9051
+ * CLI `agent list` view (decouple-client-from-identity §4.5.1 case (b)) —
9052
+ * the web roster, by contrast, stays org-scoped via
9053
+ * {@link agentVisibilityCondition}.
9054
+ *
9055
+ * Returns every active agent whose manager is an active member of the
9056
+ * caller. JOINs `agents → members.id` and filters by `members.user_id`.
9057
+ */
9058
+ async function listAgentsManagedByUser(db, userId) {
9059
+ return db.select({
9060
+ uuid: agents.uuid,
9061
+ name: agents.name,
9062
+ displayName: agents.displayName,
9063
+ type: agents.type,
9064
+ organizationId: agents.organizationId,
9065
+ inboxId: agents.inboxId,
9066
+ visibility: agents.visibility,
9067
+ runtimeProvider: agents.runtimeProvider,
9068
+ clientId: agents.clientId
9069
+ }).from(agents).innerJoin(members, eq(agents.managerId, members.id)).where(and(eq(members.userId, userId), eq(members.status, "active"), ne(agents.status, AGENT_STATUSES.DELETED)));
8724
9070
  }
8725
9071
  /**
8726
9072
  * Maps internal Chats to external IM platform channels.
@@ -8997,6 +9343,7 @@ async function findExternalMessageByInternalId(db, platform, messageId) {
8997
9343
  }).from(adapterMessageReferences).where(and(eq(adapterMessageReferences.platform, platform), eq(adapterMessageReferences.messageId, messageId))).limit(1);
8998
9344
  return row ?? null;
8999
9345
  }
9346
+ const orgQuerySchema$3 = z.object({ organizationId: z.string().min(1).optional() });
9000
9347
  function parseId$1(raw) {
9001
9348
  const id = Number(raw);
9002
9349
  if (!Number.isInteger(id) || id <= 0) throw new BadRequestError(`Invalid mapping ID: "${raw}"`);
@@ -9005,8 +9352,11 @@ function parseId$1(raw) {
9005
9352
  async function adminAdapterMappingRoutes(app) {
9006
9353
  app.get("/", async (request) => {
9007
9354
  const scope = memberScope(request);
9008
- const conditions = [eq(agents.organizationId, scope.organizationId)];
9009
- if (scope.role !== "admin") conditions.push(eq(agents.managerId, scope.memberId));
9355
+ const { organizationId } = orgQuerySchema$3.parse(request.query);
9356
+ const targetOrgId = organizationId ?? scope.organizationId;
9357
+ const probe = await requireMemberInOrg(app.db, request, targetOrgId);
9358
+ const conditions = [eq(agents.organizationId, targetOrgId)];
9359
+ if (probe.role !== "admin") conditions.push(eq(agents.managerId, probe.memberId));
9010
9360
  return (await app.db.select({
9011
9361
  id: adapterAgentMappings.id,
9012
9362
  platform: adapterAgentMappings.platform,
@@ -9073,11 +9423,15 @@ const adapterConfigs = pgTable("adapter_configs", {
9073
9423
  createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
9074
9424
  updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
9075
9425
  }, (t) => [unique("uq_adapter_configs_agent_platform").on(t.agentId, t.platform)]);
9426
+ const orgQuerySchema$2 = z.object({ organizationId: z.string().min(1).optional() });
9076
9427
  async function adminAdapterStatusRoutes(app) {
9077
9428
  app.get("/", async (request) => {
9078
9429
  const scope = memberScope(request);
9079
- const conditions = [eq(agents.organizationId, scope.organizationId), ne(agents.status, "deleted")];
9080
- if (scope.role !== "admin") conditions.push(eq(agents.managerId, scope.memberId));
9430
+ const { organizationId } = orgQuerySchema$2.parse(request.query);
9431
+ const targetOrgId = organizationId ?? scope.organizationId;
9432
+ const probe = await requireMemberInOrg(app.db, request, targetOrgId);
9433
+ const conditions = [eq(agents.organizationId, targetOrgId), ne(agents.status, "deleted")];
9434
+ if (probe.role !== "admin") conditions.push(eq(agents.managerId, probe.memberId));
9081
9435
  const visibleRows = await app.db.select({ id: adapterConfigs.id }).from(adapterConfigs).innerJoin(agents, eq(agents.uuid, adapterConfigs.agentId)).where(and(...conditions));
9082
9436
  const visibleIds = new Set(visibleRows.map((r) => r.id));
9083
9437
  if (visibleIds.size === 0) return [];
@@ -9264,6 +9618,7 @@ async function deleteAdapterConfig(db, id) {
9264
9618
  if (!row) throw new NotFoundError(`Adapter config "${id}" not found`);
9265
9619
  }
9266
9620
  const log$5 = createLogger$1("AdminAdapters");
9621
+ const orgQuerySchema$1 = z.object({ organizationId: z.string().min(1).optional() });
9267
9622
  function parseId(raw) {
9268
9623
  const id = Number(raw);
9269
9624
  if (!Number.isInteger(id) || id <= 0) throw new BadRequestError(`Invalid adapter ID: "${raw}"`);
@@ -9272,7 +9627,9 @@ function parseId(raw) {
9272
9627
  async function adminAdapterRoutes(app) {
9273
9628
  app.get("/", async (request) => {
9274
9629
  const scope = memberScope(request);
9275
- return (await listAdapterConfigsForMember(app.db, scope)).map((c) => ({
9630
+ const { organizationId } = orgQuerySchema$1.parse(request.query);
9631
+ const effective = await resolveAdminScope(app.db, request, scope, organizationId);
9632
+ return (await listAdapterConfigsForMember(app.db, effective)).map((c) => ({
9276
9633
  ...c,
9277
9634
  createdAt: c.createdAt.toISOString(),
9278
9635
  updatedAt: c.updatedAt.toISOString()
@@ -9401,20 +9758,6 @@ const agentConfigs = pgTable("agent_configs", {
9401
9758
  updatedBy: text("updated_by").notNull(),
9402
9759
  updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
9403
9760
  });
9404
- /** Organization membership. Links a user to an org with a role and a 1:1 human agent. */
9405
- const members = pgTable("members", {
9406
- id: text("id").primaryKey(),
9407
- userId: text("user_id").notNull().references(() => users.id),
9408
- organizationId: text("organization_id").notNull().references(() => organizations.id),
9409
- agentId: text("agent_id").unique().notNull().references(() => agents.uuid),
9410
- role: text("role").notNull(),
9411
- status: text("status").notNull().default("active"),
9412
- createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
9413
- }, (table) => [
9414
- unique("uq_members_user_org").on(table.userId, table.organizationId),
9415
- index("idx_members_user").on(table.userId),
9416
- index("idx_members_org").on(table.organizationId)
9417
- ]);
9418
9761
  /**
9419
9762
  * Names beginning with `__` are reserved for Hub-internal pseudo agents
9420
9763
  * (e.g. the task notifier). User-facing creation must not be able to
@@ -9506,19 +9849,14 @@ async function resolveAgentClient(db, data) {
9506
9849
  return null;
9507
9850
  }
9508
9851
  if (!data.clientId) return null;
9509
- const [manager] = await db.select({
9510
- userId: members.userId,
9511
- organizationId: members.organizationId
9512
- }).from(members).where(eq(members.id, data.managerId)).limit(1);
9852
+ const [manager] = await db.select({ userId: members.userId }).from(members).where(eq(members.id, data.managerId)).limit(1);
9513
9853
  if (!manager) throw new BadRequestError(`Manager "${data.managerId}" not found`);
9514
9854
  const [client] = await db.select({
9515
9855
  id: clients.id,
9516
- userId: clients.userId,
9517
- organizationId: clients.organizationId
9856
+ userId: clients.userId
9518
9857
  }).from(clients).where(eq(clients.id, data.clientId)).limit(1);
9519
9858
  if (!client) throw new BadRequestError(`Client "${data.clientId}" not found`);
9520
9859
  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.`);
9521
- 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.`);
9522
9860
  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.`);
9523
9861
  return client.id;
9524
9862
  }
@@ -9662,7 +10000,7 @@ async function listAgentsForAdmin(db, scope, limit, cursor) {
9662
10000
  * Uses agentVisibilityCondition from access-control (same rules for all roles).
9663
10001
  */
9664
10002
  async function listAgentsForMember(db, scope, limit, cursor, type) {
9665
- const conditions = [agentVisibilityCondition(scope)];
10003
+ const conditions = [agentVisibilityCondition(scope.organizationId, scope.memberId)];
9666
10004
  if (cursor) conditions.push(lt(agents.createdAt, new Date(cursor)));
9667
10005
  if (type) conditions.push(eq(agents.type, type));
9668
10006
  const where = and(...conditions);
@@ -10249,54 +10587,40 @@ async function cleanupStalePresence(db, staleSeconds = 60) {
10249
10587
  }
10250
10588
  /**
10251
10589
  * Assert the caller can act on this client. Throws 404 for both "not found"
10252
- * and "not yours" to prevent UUID enumeration across org/user boundaries.
10253
- *
10254
- * A client is bound to exactly one organization (`clients.organization_id`).
10255
- * Access is granted when:
10256
- * - member: row.user_id == scope.userId AND row.organization_id == scope.organizationId.
10257
- * - admin: row.organization_id == scope.organizationId AND the owner is a
10258
- * member of that same org (defense in depth).
10259
- *
10260
- * Same user across two orgs has two distinct client rows; operating on one
10261
- * while logged into the other is refused by the org filter.
10590
+ * and "not yours" to prevent UUID enumeration. The client is owned by exactly
10591
+ * one user; cross-user admin access is no longer supported by this code path
10592
+ * (see decouple-client-from-identity-design §4.10.5 option A). Cross-user
10593
+ * ownership transfer goes through `claimClient` in PR-B.
10262
10594
  */
10263
10595
  async function assertClientOwner(db, clientId, scope) {
10264
10596
  const [row] = await db.select({
10265
10597
  id: clients.id,
10266
- userId: clients.userId,
10267
- organizationId: clients.organizationId
10598
+ userId: clients.userId
10268
10599
  }).from(clients).where(eq(clients.id, clientId)).limit(1);
10269
- if (!row) throw new NotFoundError(`Client "${clientId}" not found`);
10270
- if (row.organizationId !== scope.organizationId) throw new NotFoundError(`Client "${clientId}" not found`);
10271
- if (row.userId === scope.userId) return;
10272
- if (scope.role === "admin" && row.userId !== null) {
10273
- const [sibling] = await db.select({ id: members.id }).from(members).where(and(eq(members.userId, row.userId), eq(members.organizationId, scope.organizationId))).limit(1);
10274
- if (sibling) return;
10275
- }
10276
- throw new NotFoundError(`Client "${clientId}" not found`);
10600
+ if (!row || row.userId !== scope.userId) throw new NotFoundError(`Client "${clientId}" not found`);
10277
10601
  }
10278
10602
  /**
10279
10603
  * Upsert the clients row for a given `client_id` under an authenticated user.
10280
10604
  *
10281
- * Claim semantics (see proposal M13 + multi-tenancy hardening):
10282
- * - New client_id → INSERT with the authenticated user_id and org_id.
10283
- * - Existing row with the same user_id + org_id refresh runtime columns.
10284
- * - Existing row in a different org {@link ClientOrgMismatchError}. A
10285
- * client is bound to one org for its lifetime; the CLI reacts by
10286
- * abandoning the local clientId and registering a new one.
10287
- * - Existing row with a different user_id (same org) → {@link ForbiddenError};
10288
- * the operator must pick a different clientId. Hard conflict because
10289
- * pinned agents under that client belong to the original owner.
10605
+ * Claim semantics (decouple-client-from-identity §4.1.1):
10606
+ * - New client_id → INSERT with the authenticated user_id. `organization_id`
10607
+ * is written as a placeholder (NOT NULL legacy column; no longer consumed
10608
+ * by any read path) sourced from the caller-supplied JWT default org.
10609
+ * - Existing row with the same user_id refresh runtime columns.
10610
+ * `organization_id` is **not** updated on conflict, so the placeholder set
10611
+ * at first insert sticks for the row's lifetime.
10612
+ * - Existing row with a different user_id raises
10613
+ * {@link ClientUserMismatchError} (WS close 4403). The CLI guides the
10614
+ * operator through `first-tree-hub client claim --confirm` to take
10615
+ * ownership, which unpins the previous owner's agents from the machine.
10290
10616
  */
10291
10617
  async function registerClient(db, data) {
10292
10618
  const now = /* @__PURE__ */ new Date();
10293
10619
  const [existing] = await db.select({
10294
10620
  id: clients.id,
10295
- userId: clients.userId,
10296
- organizationId: clients.organizationId
10621
+ userId: clients.userId
10297
10622
  }).from(clients).where(eq(clients.id, data.clientId)).limit(1);
10298
- if (existing && existing.organizationId !== data.organizationId) throw new ClientOrgMismatchError$1(`Client "${data.clientId}" is bound to a different organization. Re-register as a new client under the current org.`);
10299
- 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.`);
10623
+ if (existing?.userId && existing.userId !== data.userId) throw new ClientUserMismatchError$1(`Client "${data.clientId}" is owned by a different user. Run \`first-tree-hub client claim --confirm\` to transfer ownership.`);
10300
10624
  await db.insert(clients).values({
10301
10625
  id: data.clientId,
10302
10626
  userId: data.userId,
@@ -10322,6 +10646,54 @@ async function registerClient(db, data) {
10322
10646
  }
10323
10647
  });
10324
10648
  }
10649
+ /**
10650
+ * Transfer ownership of a client row to a new user, unpinning any agents
10651
+ * whose manager belonged to the previous owner. Atomic: caller is guaranteed
10652
+ * either a fully-applied ownership flip + bulk unpin, or no change. Idempotent
10653
+ * when `newUserId` already owns the row.
10654
+ *
10655
+ * Manager → user resolution goes through the members JOIN (the agents table
10656
+ * carries only `manager_id`); cross-org agents under the same previous owner
10657
+ * are unpinned together (decouple-client-from-identity §4.4).
10658
+ *
10659
+ * Caller is responsible for the caller-side authorization (the new owner must
10660
+ * be the authenticated request's user). The structured log
10661
+ * `event: client.owner_transfer` is emitted by the caller after the
10662
+ * transaction commits, using the returned `previousUserId` /
10663
+ * `unpinnedAgentIds`.
10664
+ */
10665
+ async function claimClient(db, clientId, newUserId) {
10666
+ return db.transaction(async (tx) => {
10667
+ const [locked] = await tx.execute(sql`SELECT id, user_id FROM clients WHERE id = ${clientId} FOR UPDATE`);
10668
+ if (!locked) throw new NotFoundError(`Client "${clientId}" not found`);
10669
+ const previousUserId = locked.user_id;
10670
+ if (previousUserId === newUserId) return {
10671
+ previousUserId,
10672
+ unpinnedAgentIds: []
10673
+ };
10674
+ let unpinnedAgentIds = [];
10675
+ if (previousUserId !== null) {
10676
+ unpinnedAgentIds = (await tx.select({ uuid: agents.uuid }).from(agents).innerJoin(members, eq(agents.managerId, members.id)).where(and(eq(agents.clientId, clientId), eq(members.userId, previousUserId)))).map((r) => r.uuid);
10677
+ if (unpinnedAgentIds.length > 0) {
10678
+ const now = /* @__PURE__ */ new Date();
10679
+ await tx.update(agents).set({
10680
+ clientId: null,
10681
+ updatedAt: now
10682
+ }).where(inArray(agents.uuid, unpinnedAgentIds));
10683
+ await tx.update(agentPresence).set({
10684
+ status: "offline",
10685
+ clientId: null,
10686
+ ...runtimeFieldsReset(now)
10687
+ }).where(inArray(agentPresence.agentId, unpinnedAgentIds));
10688
+ }
10689
+ }
10690
+ await tx.update(clients).set({ userId: newUserId }).where(eq(clients.id, clientId));
10691
+ return {
10692
+ previousUserId,
10693
+ unpinnedAgentIds
10694
+ };
10695
+ });
10696
+ }
10325
10697
  async function disconnectClient(db, clientId) {
10326
10698
  const now = /* @__PURE__ */ new Date();
10327
10699
  await db.update(agentPresence).set({
@@ -10360,16 +10732,17 @@ async function listActiveAgentsPinnedToClient(db, clientId) {
10360
10732
  }).from(agents).where(and(eq(agents.clientId, clientId), ne(agents.status, "deleted")));
10361
10733
  }
10362
10734
  /**
10363
- * Member-scoped: every active agent pinned to a client owned by this user
10364
- * within the given organization. Used by client startup to reconcile its
10365
- * local YAML against the authoritative `agents.runtime_provider`.
10735
+ * Member-scoped: every active agent pinned to a client owned by this user.
10736
+ * Used by client startup to reconcile its local YAML against the authoritative
10737
+ * `agents.runtime_provider`. Cross-org by design a client is owned by a
10738
+ * user, not an org (decouple-client-from-identity §4.1).
10366
10739
  */
10367
10740
  async function listMyPinnedAgents(db, scope) {
10368
10741
  return (await db.select({
10369
10742
  agentId: agents.uuid,
10370
10743
  clientId: agents.clientId,
10371
10744
  runtimeProvider: agents.runtimeProvider
10372
- }).from(agents).innerJoin(clients, eq(agents.clientId, clients.id)).where(and(eq(clients.userId, scope.userId), eq(clients.organizationId, scope.organizationId), ne(agents.status, "deleted")))).filter((r) => r.clientId !== null).map((r) => ({
10745
+ }).from(agents).innerJoin(clients, eq(agents.clientId, clients.id)).where(and(eq(clients.userId, scope.userId), ne(agents.status, "deleted")))).filter((r) => r.clientId !== null).map((r) => ({
10373
10746
  agentId: r.agentId,
10374
10747
  clientId: r.clientId,
10375
10748
  runtimeProvider: r.runtimeProvider
@@ -10394,17 +10767,28 @@ async function updateClientCapabilities(db, clientId, capabilities) {
10394
10767
  await db.update(clients).set({ metadata: merged }).where(eq(clients.id, clientId));
10395
10768
  }
10396
10769
  /**
10397
- * Scope-aware client listing.
10398
- *
10399
- * - member: rows where `user_id = scope.userId` AND `organization_id = scope.organizationId`
10400
- * — protects against a user listing their own clients registered under a
10401
- * different org when they're logged into this one.
10402
- * - admin: every row in `scope.organizationId`, regardless of owner.
10770
+ * Scope-aware client listing. Returns the caller's own clients (cross-org —
10771
+ * a client is owned by a user, not an org). The admin route adds a separate
10772
+ * `?organizationId=` cross-user view via {@link listClientsForOrgAdmin}.
10403
10773
  */
10404
10774
  async function listClients(db, scope) {
10405
- const rows = scope.role === "admin" ? await db.select({
10775
+ return attachAgentCounts(db, await db.select().from(clients).where(eq(clients.userId, scope.userId)));
10776
+ }
10777
+ /**
10778
+ * Admin-only cross-user listing: every client owned by an active member of
10779
+ * `orgId`. Joining `clients → members.user_id` instead of `clients.organization_id`
10780
+ * keeps the read path consistent with the rule that connection has no
10781
+ * runtime relationship to organization (decouple-client-from-identity §A).
10782
+ *
10783
+ * The caller must verify admin role realtime via `requireMemberInOrg` before
10784
+ * invoking this function — the service does not re-check, so it is
10785
+ * unsafe to expose without that gate.
10786
+ */
10787
+ async function listClientsForOrgAdmin(db, orgId) {
10788
+ return attachAgentCounts(db, await db.select({
10406
10789
  id: clients.id,
10407
10790
  userId: clients.userId,
10791
+ organizationId: clients.organizationId,
10408
10792
  status: clients.status,
10409
10793
  sdkVersion: clients.sdkVersion,
10410
10794
  hostname: clients.hostname,
@@ -10413,7 +10797,9 @@ async function listClients(db, scope) {
10413
10797
  connectedAt: clients.connectedAt,
10414
10798
  lastSeenAt: clients.lastSeenAt,
10415
10799
  metadata: clients.metadata
10416
- }).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)));
10800
+ }).from(clients).innerJoin(members, eq(members.userId, clients.userId)).where(and(eq(members.organizationId, orgId), eq(members.status, "active"))));
10801
+ }
10802
+ async function attachAgentCounts(db, rows) {
10417
10803
  const counts = await db.select({
10418
10804
  clientId: agents.clientId,
10419
10805
  count: sql`count(*)::int`
@@ -10723,7 +11109,7 @@ async function listAgentsWithRuntime(db, scope) {
10723
11109
  totalSessions: agentPresence.totalSessions,
10724
11110
  runtimeUpdatedAt: agentPresence.runtimeUpdatedAt,
10725
11111
  type: agents.type
10726
- }).from(agentPresence).innerJoin(agents, eq(agentPresence.agentId, agents.uuid)).where(and(isNotNull(agentPresence.runtimeState), agentVisibilityCondition(scope)));
11112
+ }).from(agentPresence).innerJoin(agents, eq(agentPresence.agentId, agents.uuid)).where(and(isNotNull(agentPresence.runtimeState), agentVisibilityCondition(scope.organizationId, scope.memberId)));
10727
11113
  }
10728
11114
  const log$4 = createLogger$1("message");
10729
11115
  async function sendMessage(db, chatId, senderId, data, options = {}) {
@@ -11089,12 +11475,26 @@ async function adminAgentRoutes(app) {
11089
11475
  }
11090
11476
  sendToClient(agent.clientId, parsed.data);
11091
11477
  }
11092
- const listAgentsFilterSchema = z.object({ type: agentTypeSchema$1.optional() });
11478
+ const listAgentsFilterSchema = z.object({
11479
+ type: agentTypeSchema$1.optional(),
11480
+ organizationId: z.string().min(1).optional()
11481
+ });
11093
11482
  app.get("/", async (request) => {
11094
11483
  const query = paginationQuerySchema.parse(request.query);
11095
- const { type } = listAgentsFilterSchema.parse(request.query);
11484
+ const { type, organizationId } = listAgentsFilterSchema.parse(request.query);
11096
11485
  const scope = memberScope(request);
11097
- const result = await listAgentsForMember(app.db, scope, query.limit, query.cursor, type);
11486
+ const targetOrgId = organizationId ?? scope.organizationId;
11487
+ let queryScope = scope;
11488
+ if (targetOrgId !== scope.organizationId) {
11489
+ const probe = await requireMemberInOrg(app.db, request, targetOrgId);
11490
+ queryScope = {
11491
+ ...scope,
11492
+ memberId: probe.memberId,
11493
+ organizationId: targetOrgId,
11494
+ role: probe.role
11495
+ };
11496
+ }
11497
+ const result = await listAgentsForMember(app.db, queryScope, query.limit, query.cursor, type);
11098
11498
  return {
11099
11499
  items: result.items.map((a) => ({
11100
11500
  ...a,
@@ -11119,9 +11519,18 @@ async function adminAgentRoutes(app) {
11119
11519
  */
11120
11520
  app.get("/all", async (request) => {
11121
11521
  const scope = memberScope(request);
11122
- if (scope.role !== "admin") throw new ForbiddenError("Admin role required");
11123
11522
  const query = paginationQuerySchema.parse(request.query);
11124
- const result = await listAgentsForAdmin(app.db, scope, query.limit, query.cursor);
11523
+ const { organizationId } = listAgentsFilterSchema.parse(request.query);
11524
+ const targetOrgId = organizationId ?? scope.organizationId;
11525
+ const probe = await requireMemberInOrg(app.db, request, targetOrgId);
11526
+ if (probe.role !== "admin") throw new ForbiddenError("Admin role required");
11527
+ const adminScope = {
11528
+ ...scope,
11529
+ memberId: probe.memberId,
11530
+ organizationId: targetOrgId,
11531
+ role: probe.role
11532
+ };
11533
+ const result = await listAgentsForAdmin(app.db, adminScope, query.limit, query.cursor);
11125
11534
  return {
11126
11535
  items: result.items.map((a) => ({
11127
11536
  ...a,
@@ -11152,9 +11561,12 @@ async function adminAgentRoutes(app) {
11152
11561
  app.post("/", async (request, reply) => {
11153
11562
  const scope = memberScope(request);
11154
11563
  const body = createAgentSchema.parse(request.body);
11155
- const managerId = scope.role === "admin" ? body.managerId ?? scope.memberId : scope.memberId;
11564
+ const targetOrgId = body.organizationId ?? scope.organizationId;
11565
+ const probe = await requireMemberInOrg(app.db, request, targetOrgId);
11566
+ const managerId = probe.role === "admin" ? body.managerId ?? probe.memberId : probe.memberId;
11156
11567
  const agent = await createAgent(app.db, {
11157
11568
  ...body,
11569
+ organizationId: targetOrgId,
11158
11570
  source: body.source ?? "admin-api",
11159
11571
  managerId
11160
11572
  });
@@ -11461,7 +11873,8 @@ async function adminChatRoutes(app) {
11461
11873
  /** List all chats in org (admin-only, for audit). Members should use GET /mine. */
11462
11874
  app.get("/", { preHandler: requireAdminRoleHook() }, async (request) => {
11463
11875
  const query = paginationQuerySchema.parse(request.query);
11464
- const orgParam = request.query.org;
11876
+ const rawQuery = request.query;
11877
+ const orgParam = rawQuery.organizationId ?? rawQuery.org;
11465
11878
  let orgId;
11466
11879
  if (orgParam) orgId = (await resolveOrganization(app.db, orgParam)).id;
11467
11880
  else orgId = await resolveDefaultOrgId(app.db);
@@ -11655,14 +12068,15 @@ async function adminChatRoutes(app) {
11655
12068
  function serializeDate(d) {
11656
12069
  return d ? d.toISOString() : null;
11657
12070
  }
12071
+ const listClientsQuerySchema = z.object({ organizationId: z.string().min(1).optional() });
11658
12072
  async function adminClientRoutes(app) {
11659
12073
  app.get("/", async (request) => {
11660
12074
  const scope = memberScope(request);
11661
- return (await listClients(app.db, {
11662
- userId: scope.userId,
11663
- organizationId: scope.organizationId,
11664
- role: scope.role
11665
- })).map((c) => ({
12075
+ const { organizationId } = listClientsQuerySchema.parse(request.query);
12076
+ return (organizationId ? await (async () => {
12077
+ if ((await requireMemberInOrg(app.db, request, organizationId)).role !== "admin") throw new ForbiddenError("Admin role required");
12078
+ return listClientsForOrgAdmin(app.db, organizationId);
12079
+ })() : await listClients(app.db, { userId: scope.userId })).map((c) => ({
11666
12080
  id: c.id,
11667
12081
  userId: c.userId,
11668
12082
  status: c.status,
@@ -11676,10 +12090,7 @@ async function adminClientRoutes(app) {
11676
12090
  });
11677
12091
  app.get("/me/agents", async (request) => {
11678
12092
  const scope = memberScope(request);
11679
- return await listMyPinnedAgents(app.db, {
11680
- userId: scope.userId,
11681
- organizationId: scope.organizationId
11682
- });
12093
+ return await listMyPinnedAgents(app.db, { userId: scope.userId });
11683
12094
  });
11684
12095
  app.patch("/:clientId/capabilities", async (request, reply) => {
11685
12096
  const scope = memberScope(request);
@@ -12014,25 +12425,33 @@ async function pushToWebhook(db, notification) {
12014
12425
  });
12015
12426
  } catch {}
12016
12427
  }
12428
+ const orgQuerySchema = z.object({ organizationId: z.string().min(1).optional() });
12017
12429
  async function adminNotificationRoutes(app) {
12018
12430
  /**
12019
- * GET /admin/notifications — list notifications visible to the caller.
12431
+ * GET /admin/notifications — list notifications visible to the caller in
12432
+ * the **selected** organization. The web client passes
12433
+ * `?organizationId=<selectedOrgId>` so a non-default org doesn't reuse
12434
+ * the JWT default org's bell feed (codex P1 #2). Defaults to JWT org
12435
+ * when omitted.
12020
12436
  *
12021
- * Scoped by (a) organization (via JWT) and (b) per-agent visibility: the
12022
- * member only sees notifications whose agentId is visible to them
12023
- * (organization-visible agents or agents they manage), plus org-wide
12024
- * system notifications with no agentId. This mirrors the rule the admin
12025
- * WebSocket route enforces on live pushes — REST and WS stay in sync.
12437
+ * Per-agent visibility filter: the member only sees notifications whose
12438
+ * agentId is visible to them in that org (organization-visible agents or
12439
+ * agents they manage), plus org-wide system notifications with no
12440
+ * agentId. This mirrors the admin-WS push gate so REST and WS stay in sync.
12026
12441
  */
12027
12442
  app.get("/", async (request) => {
12028
- const member = requireMember(request);
12443
+ const scope = memberScope(request);
12444
+ const { organizationId } = orgQuerySchema.parse(request.query);
12445
+ const effective = await resolveAdminScope(app.db, request, scope, organizationId);
12029
12446
  const query = notificationQuerySchema.parse(request.query);
12030
- return listNotifications(app.db, member.organizationId, member.memberId, query);
12447
+ return listNotifications(app.db, effective.organizationId, effective.memberId, query);
12031
12448
  });
12032
12449
  /** POST /admin/notifications/:id/read — mark a single notification as read */
12033
12450
  app.post("/:id/read", async (request) => {
12034
- const member = requireMember(request);
12035
- const result = await markRead(app.db, request.params.id, member.organizationId, member.memberId);
12451
+ const scope = memberScope(request);
12452
+ const { organizationId } = orgQuerySchema.parse(request.query);
12453
+ const effective = await resolveAdminScope(app.db, request, scope, organizationId);
12454
+ const result = await markRead(app.db, request.params.id, effective.organizationId, effective.memberId);
12036
12455
  if (!result) throw new NotFoundError(`Notification "${request.params.id}" not found`);
12037
12456
  return {
12038
12457
  ...result,
@@ -12041,8 +12460,10 @@ async function adminNotificationRoutes(app) {
12041
12460
  });
12042
12461
  /** POST /admin/notifications/read-all — mark all visible notifications as read */
12043
12462
  app.post("/read-all", async (request) => {
12044
- const member = requireMember(request);
12045
- await markAllRead(app.db, member.organizationId, member.memberId);
12463
+ const scope = memberScope(request);
12464
+ const { organizationId } = orgQuerySchema.parse(request.query);
12465
+ const effective = await resolveAdminScope(app.db, request, scope, organizationId);
12466
+ await markAllRead(app.db, effective.organizationId, effective.memberId);
12046
12467
  return { status: "ok" };
12047
12468
  });
12048
12469
  }
@@ -12094,7 +12515,8 @@ async function adminOrganizationRoutes(app) {
12094
12515
  }
12095
12516
  async function adminOverviewRoutes(app) {
12096
12517
  app.get("/", async (request) => {
12097
- const orgParam = (request.query ?? {}).org;
12518
+ const q = request.query ?? {};
12519
+ const orgParam = q.organizationId ?? q.org;
12098
12520
  let orgId;
12099
12521
  if (orgParam) orgId = (await resolveOrganization(app.db, orgParam)).id;
12100
12522
  else orgId = await resolveDefaultOrgId(app.db);
@@ -12409,17 +12831,21 @@ const globalSessionFilterSchema = paginationQuerySchema.extend({
12409
12831
  "suspended",
12410
12832
  "evicted"
12411
12833
  ]).optional(),
12412
- agentId: z.string().optional()
12834
+ agentId: z.string().optional(),
12835
+ organizationId: z.string().min(1).optional()
12413
12836
  });
12414
12837
  async function adminSessionRoutes(app) {
12415
- /** GET /admin/sessions — global session list, scoped to caller's org */
12838
+ /** GET /admin/sessions — global session list, scoped to the selected
12839
+ * org. The web client passes `?organizationId=<selectedOrgId>` after a
12840
+ * /auth/switch-org 204 (codex P1 #2); falls back to the JWT default. */
12416
12841
  app.get("/", async (request) => {
12417
- const member = requireMember(request);
12842
+ const scope = memberScope(request);
12418
12843
  const query = globalSessionFilterSchema.parse(request.query);
12844
+ const effective = await resolveAdminScope(app.db, request, scope, query.organizationId);
12419
12845
  return listAllSessions(app.db, query.limit, query.cursor, {
12420
12846
  state: query.state,
12421
12847
  agentId: query.agentId,
12422
- organizationId: member.organizationId
12848
+ organizationId: effective.organizationId
12423
12849
  });
12424
12850
  });
12425
12851
  /** GET /admin/sessions/agents/:agentId — sessions for a specific agent.
@@ -13113,11 +13539,11 @@ function adminWsRoutes(notifier, jwtSecret) {
13113
13539
  endWsConnectionSpan(socket, 4001);
13114
13540
  return;
13115
13541
  }
13542
+ let userId;
13116
13543
  let organizationId;
13117
- let memberId;
13118
13544
  try {
13119
13545
  const { payload } = await jwtVerify(token, secret);
13120
- if (payload.type !== "access" || !payload.sub || typeof payload.organizationId !== "string" || typeof payload.memberId !== "string") {
13546
+ if (payload.type !== "access" || typeof payload.sub !== "string" || typeof payload.organizationId !== "string") {
13121
13547
  socket.send(JSON.stringify({
13122
13548
  type: "error",
13123
13549
  message: "Invalid token type"
@@ -13126,8 +13552,8 @@ function adminWsRoutes(notifier, jwtSecret) {
13126
13552
  endWsConnectionSpan(socket, 4001);
13127
13553
  return;
13128
13554
  }
13555
+ userId = payload.sub;
13129
13556
  organizationId = payload.organizationId;
13130
- memberId = payload.memberId;
13131
13557
  } catch {
13132
13558
  socket.send(JSON.stringify({
13133
13559
  type: "error",
@@ -13137,6 +13563,20 @@ function adminWsRoutes(notifier, jwtSecret) {
13137
13563
  endWsConnectionSpan(socket, 4001);
13138
13564
  return;
13139
13565
  }
13566
+ const [memberRow] = await app.db.select({
13567
+ id: members.id,
13568
+ role: members.role
13569
+ }).from(members).where(and(eq(members.userId, userId), eq(members.organizationId, organizationId), eq(members.status, "active"))).limit(1);
13570
+ if (!memberRow) {
13571
+ socket.send(JSON.stringify({
13572
+ type: "error",
13573
+ message: "Not an active member of this organization"
13574
+ }));
13575
+ socket.close(4403, "Not a member");
13576
+ endWsConnectionSpan(socket, 4403);
13577
+ return;
13578
+ }
13579
+ const memberId = memberRow.id;
13140
13580
  setWsConnectionAttrs(socket, {
13141
13581
  organizationId,
13142
13582
  memberId
@@ -13965,6 +14405,7 @@ function clientWsRoutes(notifier, instanceId) {
13965
14405
  }, async (socket) => {
13966
14406
  startWsConnectionSpan(socket);
13967
14407
  let session = null;
14408
+ let jwtDefaultOrgId = null;
13968
14409
  let clientId = null;
13969
14410
  let authExpiryTimer = null;
13970
14411
  const boundAgents = /* @__PURE__ */ new Map();
@@ -14195,28 +14636,15 @@ function clientWsRoutes(notifier, instanceId) {
14195
14636
  try {
14196
14637
  const { payload } = await jwtVerify(authParsed.data.token, jwtSecretBytes);
14197
14638
  const claims = payload;
14198
- if (claims.type !== "access" || !claims.sub || !claims.memberId) throw new Error("Invalid token claims");
14639
+ if (claims.type !== "access" || !claims.sub) throw new Error("Invalid token claims");
14199
14640
  const [user] = await app.db.select({
14200
14641
  id: users.id,
14201
14642
  status: users.status
14202
14643
  }).from(users).where(eq(users.id, claims.sub)).limit(1);
14203
14644
  if (!user || user.status !== "active") throw new Error("User not found or suspended");
14204
- const [member] = await app.db.select({
14205
- id: members.id,
14206
- organizationId: members.organizationId,
14207
- role: members.role
14208
- }).from(members).where(eq(members.id, claims.memberId)).limit(1);
14209
- if (!member) throw new Error("Membership not found");
14210
- session = {
14211
- userId: user.id,
14212
- memberId: member.id,
14213
- organizationId: member.organizationId,
14214
- role: member.role
14215
- };
14216
- setWsConnectionAttrs(socket, {
14217
- "organization.id": member.organizationId,
14218
- "member.id": member.id
14219
- });
14645
+ session = { userId: user.id };
14646
+ jwtDefaultOrgId = typeof claims.organizationId === "string" ? claims.organizationId : null;
14647
+ setWsConnectionAttrs(socket, { "user.id": user.id });
14220
14648
  clearTimeout(authTimeout);
14221
14649
  scheduleAuthExpiry(claims.exp);
14222
14650
  socket.send(JSON.stringify({ type: "auth:ok" }));
@@ -14242,11 +14670,19 @@ function clientWsRoutes(notifier, instanceId) {
14242
14670
  if (type === "client:register") {
14243
14671
  const data = clientRegisterSchema.parse(msg);
14244
14672
  clientWantsWsInboxDeliver = data.wireCapabilities?.wsInboxDeliver === true;
14673
+ if (!jwtDefaultOrgId) {
14674
+ socket.send(JSON.stringify({
14675
+ type: "client:register:rejected",
14676
+ message: "JWT missing organizationId claim"
14677
+ }));
14678
+ socket.close(4401, "client register rejected");
14679
+ return;
14680
+ }
14245
14681
  try {
14246
14682
  await registerClient(app.db, {
14247
14683
  clientId: data.clientId,
14248
14684
  userId: session.userId,
14249
- organizationId: session.organizationId,
14685
+ organizationId: jwtDefaultOrgId,
14250
14686
  instanceId,
14251
14687
  hostname: data.hostname,
14252
14688
  os: data.os,
@@ -14254,7 +14690,7 @@ function clientWsRoutes(notifier, instanceId) {
14254
14690
  });
14255
14691
  } catch (err) {
14256
14692
  const message = err instanceof Error ? err.message : "client register failed";
14257
- const code = err instanceof ClientOrgMismatchError$1 ? err.code : void 0;
14693
+ const code = err instanceof ClientUserMismatchError$1 ? err.code : err instanceof ClientOrgMismatchError$1 ? err.code : void 0;
14258
14694
  socket.send(JSON.stringify({
14259
14695
  type: "client:register:rejected",
14260
14696
  message,
@@ -14317,25 +14753,24 @@ function clientWsRoutes(notifier, instanceId) {
14317
14753
  clientId: agents.clientId,
14318
14754
  runtimeProvider: agents.runtimeProvider,
14319
14755
  clientUserId: clients.userId,
14320
- managerUserId: members.userId
14756
+ managerUserId: members.userId,
14757
+ managerMemberStatus: members.status
14321
14758
  }).from(agents).leftJoin(clients, eq(agents.clientId, clients.id)).leftJoin(members, eq(agents.managerId, members.id)).where(and(eq(agents.uuid, bindRequest.agentId))).limit(1);
14322
14759
  if (!agent) {
14323
14760
  sendRejected(socket, ref, AGENT_BIND_REJECT_REASONS.UNKNOWN_AGENT);
14324
14761
  return;
14325
14762
  }
14326
- if (agent.organizationId !== session.organizationId) {
14327
- sendRejected(socket, ref, AGENT_BIND_REJECT_REASONS.WRONG_ORG);
14328
- return;
14329
- }
14330
14763
  if (agent.status !== "active") {
14331
14764
  sendRejected(socket, ref, AGENT_BIND_REJECT_REASONS.AGENT_SUSPENDED);
14332
14765
  return;
14333
14766
  }
14767
+ const ownerOk = agent.managerUserId !== null && agent.managerUserId === session.userId;
14768
+ const membershipActive = agent.managerMemberStatus === "active";
14769
+ if (!ownerOk || !membershipActive) {
14770
+ sendRejected(socket, ref, AGENT_BIND_REJECT_REASONS.NOT_OWNED);
14771
+ return;
14772
+ }
14334
14773
  if (agent.clientId === null) {
14335
- if (!agent.managerUserId || agent.managerUserId !== session.userId) {
14336
- sendRejected(socket, ref, AGENT_BIND_REJECT_REASONS.NOT_OWNED);
14337
- return;
14338
- }
14339
14774
  if ((await app.db.update(agents).set({
14340
14775
  clientId,
14341
14776
  updatedAt: /* @__PURE__ */ new Date()
@@ -14363,7 +14798,8 @@ function clientWsRoutes(notifier, instanceId) {
14363
14798
  bindAgentToClient(clientId, agent.id);
14364
14799
  boundAgents.set(agent.id, {
14365
14800
  agentId: agent.id,
14366
- inboxId: agent.inboxId
14801
+ inboxId: agent.inboxId,
14802
+ organizationId: agent.organizationId
14367
14803
  });
14368
14804
  const wsPushActive = pushUseWsDataPlane();
14369
14805
  if (wsPushActive) notifier.subscribe(agent.inboxId, socket, makeInboxPushHandler(agent.id, agent.inboxId));
@@ -14423,7 +14859,9 @@ function clientWsRoutes(notifier, instanceId) {
14423
14859
  }, "session:state rejected — stale client wire");
14424
14860
  return;
14425
14861
  }
14426
- await upsertSessionState(app.db, agentId, payloadResult.data.chatId, payloadResult.data.state, session.organizationId, notifier);
14862
+ const boundAgentInfo = boundAgents.get(agentId);
14863
+ if (!boundAgentInfo) return;
14864
+ await upsertSessionState(app.db, agentId, payloadResult.data.chatId, payloadResult.data.state, boundAgentInfo.organizationId, notifier);
14427
14865
  } else if (type === "session:reconcile") {
14428
14866
  const agentId = parsed.data.agentId;
14429
14867
  if (!agentId || !boundAgents.has(agentId)) {
@@ -14460,8 +14898,10 @@ function clientWsRoutes(notifier, instanceId) {
14460
14898
  return;
14461
14899
  }
14462
14900
  const payload = runtimeStateMessageSchema.parse(msg);
14901
+ const boundAgentInfo = boundAgents.get(agentId);
14902
+ if (!boundAgentInfo) return;
14463
14903
  await setRuntimeState(app.db, agentId, payload.runtimeState, {
14464
- organizationId: session.organizationId,
14904
+ organizationId: boundAgentInfo.organizationId,
14465
14905
  notifier
14466
14906
  });
14467
14907
  if (payload.runtimeState === "error" && shouldNotify(agentId, "agent_error")) notifyAgentEvent(app.db, agentId, "agent_error", "high").catch(() => {});
@@ -14983,6 +15423,7 @@ async function listActiveMemberships(db, userId) {
14983
15423
  memberId: members.id,
14984
15424
  organizationId: members.organizationId,
14985
15425
  role: members.role,
15426
+ agentId: members.agentId,
14986
15427
  orgName: organizations.name,
14987
15428
  orgDisplayName: organizations.displayName,
14988
15429
  createdAt: members.createdAt
@@ -15324,6 +15765,36 @@ async function bootstrapConfigRoutes(app) {
15324
15765
  return { allowedOrg: app.config.github.allowedOrg ?? null };
15325
15766
  });
15326
15767
  }
15768
+ /**
15769
+ * Member-scoped client routes.
15770
+ *
15771
+ * Mounted at `/me/clients` to keep them off the admin surface (the legacy
15772
+ * admin clients router lives at `/clients`). The only operation here is
15773
+ * `POST /:clientId/claim`: ownership transfer driven by the operator running
15774
+ * `first-tree-hub client claim --confirm` after a 4403 handshake mismatch
15775
+ * (decouple-client-from-identity §4.4).
15776
+ */
15777
+ async function memberClientRoutes(app) {
15778
+ app.post("/:clientId/claim", async (request, reply) => {
15779
+ const m = requireMember(request);
15780
+ const { clientId } = request.params;
15781
+ const result = await claimClient(app.db, clientId, m.userId);
15782
+ const droppedAgentIds = forceDisconnectClient(clientId);
15783
+ request.log.info({
15784
+ event: "client.owner_transfer",
15785
+ clientId,
15786
+ fromUserId: result.previousUserId,
15787
+ toUserId: m.userId,
15788
+ unpinnedAgentCount: result.unpinnedAgentIds.length,
15789
+ droppedSocketAgentCount: droppedAgentIds.length
15790
+ }, "client ownership transferred via /me/clients/:clientId/claim");
15791
+ return reply.status(200).send({
15792
+ clientId,
15793
+ previousUserId: result.previousUserId,
15794
+ unpinnedAgentCount: result.unpinnedAgentIds.length
15795
+ });
15796
+ });
15797
+ }
15327
15798
  async function contextTreeInfoRoutes(app) {
15328
15799
  /** Public endpoint — returns Context Tree repo metadata for CLI auto-discovery. */
15329
15800
  app.get("/info", async () => {
@@ -15453,6 +15924,7 @@ async function meRoutes(app) {
15453
15924
  const inv = await getActiveInvitation(app.db, m.organizationId);
15454
15925
  if (inv) inviteUrl = buildInviteUrl(resolvePublicUrl(app, request), inv.token);
15455
15926
  }
15927
+ const memberships = await listActiveMemberships(app.db, m.userId);
15456
15928
  return {
15457
15929
  user: user ?? null,
15458
15930
  member: {
@@ -15461,6 +15933,13 @@ async function meRoutes(app) {
15461
15933
  role: m.role,
15462
15934
  agentId: m.agentId
15463
15935
  },
15936
+ memberships: memberships.map((mb) => ({
15937
+ id: mb.memberId,
15938
+ organizationId: mb.organizationId,
15939
+ organizationName: mb.orgDisplayName,
15940
+ role: mb.role,
15941
+ agentId: mb.agentId
15942
+ })),
15464
15943
  agent: agent ?? null,
15465
15944
  wizard: { step: wizardStep },
15466
15945
  inviteUrl
@@ -15494,6 +15973,20 @@ async function meRoutes(app) {
15494
15973
  command: `first-tree-hub connect ${token}`
15495
15974
  };
15496
15975
  });
15976
+ app.get("/me/managed-agents", async (request) => {
15977
+ const m = requireMember(request);
15978
+ return (await listAgentsManagedByUser(app.db, m.userId)).map((r) => ({
15979
+ uuid: r.uuid,
15980
+ name: r.name,
15981
+ displayName: r.displayName,
15982
+ type: r.type,
15983
+ organizationId: r.organizationId,
15984
+ inboxId: r.inboxId,
15985
+ visibility: r.visibility,
15986
+ runtimeProvider: r.runtimeProvider,
15987
+ clientId: r.clientId
15988
+ }));
15989
+ });
15497
15990
  app.get("/me/organizations", async (request) => {
15498
15991
  const m = requireMember(request);
15499
15992
  return (await listActiveMemberships(app.db, m.userId)).map((r) => ({
@@ -15581,15 +16074,9 @@ async function meRoutes(app) {
15581
16074
  app.post("/auth/switch-org", async (request, reply) => {
15582
16075
  const m = requireMember(request);
15583
16076
  const body = switchOrgSchema.parse(request.body);
15584
- const [target] = await app.db.select().from(members).where(and(eq(members.userId, m.userId), eq(members.organizationId, body.organizationId), eq(members.status, "active"))).limit(1);
16077
+ const [target] = await app.db.select({ id: members.id }).from(members).where(and(eq(members.userId, m.userId), eq(members.organizationId, body.organizationId), eq(members.status, "active"))).limit(1);
15585
16078
  if (!target) throw new ForbiddenError("You do not belong to that organization");
15586
- const tokens = await signTokensForMember(app.config.secrets.jwtSecret, {
15587
- userId: m.userId,
15588
- memberId: target.id,
15589
- organizationId: target.organizationId,
15590
- role: target.role
15591
- });
15592
- return reply.send(tokens);
16079
+ return reply.status(204).send();
15593
16080
  });
15594
16081
  }
15595
16082
  /**
@@ -15605,7 +16092,7 @@ async function meRoutes(app) {
15605
16092
  * that's the explicit reset path.
15606
16093
  */
15607
16094
  async function inferWizardStep(app, m) {
15608
- const [hasClient] = await app.db.select({ id: clients.id }).from(clients).where(and(eq(clients.userId, m.userId), eq(clients.organizationId, m.organizationId))).limit(1);
16095
+ const [hasClient] = await app.db.select({ id: clients.id }).from(clients).where(eq(clients.userId, m.userId)).limit(1);
15609
16096
  if (!hasClient) return "connect";
15610
16097
  const [hasAgent] = await app.db.select({ uuid: agents.uuid }).from(agents).where(and(eq(agents.managerId, m.memberId), ne(agents.type, "human"), eq(agents.status, "active"))).limit(1);
15611
16098
  if (!hasAgent) return "create_agent";
@@ -15617,7 +16104,7 @@ async function inferWizardStep(app, m) {
15617
16104
  * landing page.
15618
16105
  */
15619
16106
  async function publicInvitePreviewRoute(app) {
15620
- const { previewInvitation } = await import("./invitation-D3feYxet-366MNOor.mjs");
16107
+ const { previewInvitation } = await import("./invitation-CBnQyB7o-DVOpS_Ts.mjs");
15621
16108
  app.get("/:token/preview", async (request, reply) => {
15622
16109
  if (!request.params.token) throw new UnauthorizedError("Token required");
15623
16110
  const preview = await previewInvitation(app.db, request.params.token);
@@ -15647,7 +16134,7 @@ async function adminInvitationRoutes(app) {
15647
16134
  const m = requireMember(request);
15648
16135
  if (m.role !== "admin") throw new ForbiddenError("Admin role required");
15649
16136
  if (request.params.id !== m.organizationId) throw new ForbiddenError("Cannot rotate invitations for another organization");
15650
- const { rotateInvitation } = await import("./invitation-D3feYxet-366MNOor.mjs");
16137
+ const { rotateInvitation } = await import("./invitation-CBnQyB7o-DVOpS_Ts.mjs");
15651
16138
  const inv = await rotateInvitation(app.db, m.organizationId, m.userId);
15652
16139
  return {
15653
16140
  id: inv.id,
@@ -17629,6 +18116,10 @@ async function buildApp(config) {
17629
18116
  adminApp.addHook("onRequest", memberAuth);
17630
18117
  await adminApp.register(adminClientRoutes);
17631
18118
  }, { prefix: "/clients" });
18119
+ await api.register(async (memberApp) => {
18120
+ memberApp.addHook("onRequest", memberAuth);
18121
+ await memberApp.register(memberClientRoutes);
18122
+ }, { prefix: "/me/clients" });
17632
18123
  await api.register(async (adminApp) => {
17633
18124
  adminApp.addHook("onRequest", memberAuth);
17634
18125
  await adminApp.register(adminActivityRoutes);
@@ -18262,4 +18753,4 @@ function registerSaaSConnectCommand(program) {
18262
18753
  });
18263
18754
  }
18264
18755
  //#endregion
18265
- export { status as $, checkServerHealth as A, isDockerAvailable as B, checkAgentConfigs as C, checkDocker as D, checkDatabase as E, installClientService as F, createOwner as G, ClientRuntime as H, isServiceSupported as I, fail as J, hasUser as K, resolveCliInvocation as L, checkWebSocket as M, printResults as N, checkNodeVersion as O, getClientServiceStatus as P, setJsonMode as Q, uninstallClientService as R, runMigrations as S, checkClientConfig as T, handleClientOrgMismatch as U, stopPostgres as V, rotateClientIdWithBackup as W, blank as X, success as Y, print as Z, onboardCreate as _, declineUpdate as a, probeCapabilities as at, createApiNameResolver as b, COMMAND_VERSION as c, isInteractive as d, ClientOrgMismatchError as et, promptAddAgent as f, onboardCheck as g, loadOnboardState as h, createExecuteUpdate as i, cleanWorkspaces as it, checkServerReachable as j, checkServerConfig as k, reconcileLocalRuntimeProviders as l, formatCheckReport as m, deriveHubUrlFromToken as n, SdkError as nt, promptUpdate as o, applyClientLoggerConfig as ot, promptMissingFields as p, resolveReplyToFromEnv as q, registerSaaSConnectCommand as r, SessionRegistry as rt, startServer as s, configureClientLoggerForService as st, HubUrlDerivationError as t, FirstTreeHubSDK as tt, uploadClientCapabilities as u, saveOnboardState as v, checkBackgroundService as w, migrateLocalAgentDirs as x, runHomeMigration as y, ensurePostgres as z };
18756
+ export { success as $, checkServerHealth as A, ensurePostgres as B, checkAgentConfigs as C, checkDocker as D, checkDatabase as E, getClientServiceStatus as F, rotateClientIdWithBackup as G, stopPostgres as H, installClientService as I, removeLocalAgent as J, findStaleAliases as K, isServiceSupported as L, checkWebSocket as M, printResults as N, checkNodeVersion as O, reconcileAgentConfigs as P, fail as Q, resolveCliInvocation as R, runMigrations as S, checkClientConfig as T, ClientRuntime as U, isDockerAvailable as V, handleClientOrgMismatch as W, hasUser as X, createOwner as Y, resolveReplyToFromEnv as Z, onboardCreate as _, declineUpdate as a, ClientUserMismatchError as at, createApiNameResolver as b, COMMAND_VERSION as c, SessionRegistry as ct, isInteractive as d, applyClientLoggerConfig as dt, blank as et, promptAddAgent as f, configureClientLoggerForService as ft, onboardCheck as g, loadOnboardState as h, createExecuteUpdate as i, ClientOrgMismatchError as it, checkServerReachable as j, checkServerConfig as k, reconcileLocalRuntimeProviders as l, cleanWorkspaces as lt, formatCheckReport as m, deriveHubUrlFromToken as n, setJsonMode as nt, promptUpdate as o, FirstTreeHubSDK as ot, promptMissingFields as p, formatStaleReason as q, registerSaaSConnectCommand as r, status as rt, startServer as s, SdkError as st, HubUrlDerivationError as t, print as tt, uploadClientCapabilities as u, probeCapabilities as ut, saveOnboardState as v, checkBackgroundService as w, migrateLocalAgentDirs as x, runHomeMigration as y, uninstallClientService as z };