@agent-team-foundation/first-tree-hub 0.10.1 → 0.10.3

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.
Files changed (27) hide show
  1. package/dist/{bootstrap-CtVqQA8a.mjs → bootstrap-CBAVWQUT.mjs} +9 -1
  2. package/dist/cli/index.mjs +63 -20
  3. package/dist/{feishu-DEmwoNn_.mjs → dist-DUCelK3Z.mjs} +202 -62
  4. package/dist/drizzle/0026_saas_onboarding.sql +153 -0
  5. package/dist/drizzle/0027_runtime_provider.sql +10 -0
  6. package/dist/drizzle/0028_auth_identity_user_github_unique.sql +12 -0
  7. package/dist/drizzle/meta/_journal.json +21 -0
  8. package/dist/feishu-Boy3n8CT.mjs +52 -0
  9. package/dist/{getMachineId-bsd-BB-fnFLA.mjs → getMachineId-bsd-D0w3uAZa.mjs} +1 -1
  10. package/dist/{getMachineId-darwin-DAYWNsYK.mjs → getMachineId-darwin-DOoYFb2_.mjs} +1 -1
  11. package/dist/{getMachineId-win-H5RT49ov.mjs → getMachineId-win-B6hY8edq.mjs} +1 -1
  12. package/dist/index.mjs +7 -5
  13. package/dist/invitation-BTlGMy0o-Coj07kYi.mjs +3 -0
  14. package/dist/invitation-C_zAhB8x-8Khychlu.mjs +258 -0
  15. package/dist/{observability-DDkJwSKv.mjs → observability-C08jUFsJ.mjs} +1 -1
  16. package/dist/{observability-DV_fQKqV-oxfXX6Z2.mjs → observability-DPyf745N-BSc8QNcR.mjs} +6 -6
  17. package/dist/{core-BgiFGT7Y.mjs → saas-connect-3p-vBkuY.mjs} +2459 -430
  18. package/dist/web/assets/index-CHoaSIzI.js +21 -0
  19. package/dist/web/assets/index-CP8uLPyO.css +1 -0
  20. package/dist/web/assets/index-D7OzKrI2.js +387 -0
  21. package/dist/web/index.html +2 -2
  22. package/package.json +3 -2
  23. package/dist/web/assets/index-Cd290Lq6.css +0 -1
  24. package/dist/web/assets/index-xi7JmCtW.js +0 -361
  25. /package/dist/{execAsync-CP8iWV5b.mjs → execAsync-XMc-nFn-.mjs} +0 -0
  26. /package/dist/{getMachineId-linux-BU7Fi6S0.mjs → getMachineId-linux-MlY63Zsw.mjs} +0 -0
  27. /package/dist/{getMachineId-unsupported-BhWCxKBo.mjs → getMachineId-unsupported-BS652RIy.mjs} +0 -0
@@ -1,7 +1,8 @@
1
1
  import { m as __toESM } from "./esm-CYu4tXXn.mjs";
2
- import { _ as withSpan, a as endWsConnectionSpan, b as require_pino, c as messageAttrs, d as rootLogger$1, g as startWsConnectionSpan, i as currentTraceId, n as applyLoggerConfig, o as getFastifyOtelPlugin, p as setWsConnectionAttrs, r as createLogger$1, t as adapterAttrs, u as observabilityPlugin, v as withWsMessageSpan, y as FIRST_TREE_HUB_ATTR } from "./observability-DV_fQKqV-oxfXX6Z2.mjs";
3
- import { C as serverConfigSchema, S as resolveConfigReadonly, _ as loadAgents, d as DEFAULT_HOME_DIR$1, f as agentConfigSchema, g as initConfig, i as loadCredentials, l as DEFAULT_CONFIG_DIR, m as collectMissingPrompts, n as ensureFreshAccessToken, o as resolveServerUrl, p as clientConfigSchema, s as saveAgentConfig, u as DEFAULT_DATA_DIR$1, v as migrateLegacyHome, w as setConfigValue } from "./bootstrap-CtVqQA8a.mjs";
4
- import { $ as sessionEventMessageSchema, A as createChatSchema, B as isReservedAgentName$1, C as agentRuntimeConfigPayloadSchema$1, D as createAdapterConfigSchema, E as connectTokenExchangeSchema, F as dryRunAgentRuntimeConfigSchema, G as paginationQuerySchema, H as loginSchema, I as extractMentions, J as scanMentionTokens, K as refreshTokenSchema, L as imageInlineContentSchema, M as createOrganizationSchema, N as createTaskSchema, O as createAdapterMappingSchema, P as delegateFeishuUserSchema, Q as sessionCompletionMessageSchema, R as inboxPollQuerySchema, S as agentPinnedMessageSchema$1, T as clientRegisterSchema, U as messageSourceSchema$1, V as linkTaskChatSchema, W as notificationQuerySchema, X as sendMessageSchema, Y as selfServiceFeishuBotSchema, Z as sendToAgentSchema, _ as WS_AUTH_FRAME_TIMEOUT_MS, a as AGENT_NAME_REGEX$1, at as updateAgentRuntimeConfigSchema, b as adminUpdateTaskSchema, c as AGENT_STATUSES, ct as updateMemberSchema, d as DEFAULT_AGENT_RUNTIME_CONFIG_PAYLOAD, dt as updateTaskStatusSchema, et as sessionEventSchema$1, f as SYSTEM_CONFIG_DEFAULTS, ft as wsAuthFrameSchema, g as TASK_TERMINAL_STATUSES, h as TASK_STATUSES, i as AGENT_BIND_REJECT_REASONS, it as updateAdapterConfigSchema, j as createMemberSchema, k as createAgentSchema, l as AGENT_TYPES, lt as updateOrganizationSchema, m as TASK_HEALTH_SIGNALS, nt as sessionStateMessageSchema, o as AGENT_SELECTOR_HEADER$1, ot as updateAgentSchema, p as TASK_CREATOR_TYPES, q as runtimeStateMessageSchema, rt as taskListQuerySchema, s as AGENT_SOURCES, st as updateChatSchema, tt as sessionReconcileRequestSchema, u as AGENT_VISIBILITY, ut as updateSystemConfigSchema, v as addParticipantSchema, w as agentTypeSchema$1, x as agentBindRequestSchema, y as adminCreateTaskSchema, z as isRedactedEnvValue } from "./feishu-DEmwoNn_.mjs";
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
+ 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-CBAVWQUT.mjs";
4
+ import { $ as safeRedirectPath, A as createOrgFromMeSchema, B as imageInlineContentSchema, C as clientRegisterSchema, D as createAgentSchema, E as createAdapterMappingSchema, F as dryRunAgentRuntimeConfigSchema, G as linkTaskChatSchema, H as isRedactedEnvValue, I as extractMentions, J as notificationQuerySchema, K as loginSchema, L as githubCallbackQuerySchema, M as createTaskSchema, N as defaultRuntimeConfigPayload, O as createChatSchema, P as delegateFeishuUserSchema, Q as runtimeStateMessageSchema, R as githubDevCallbackQuerySchema, S as clientCapabilitiesSchema$1, T as createAdapterConfigSchema, U as isReservedAgentName$1, V as inboxPollQuerySchema, W as joinByInvitationSchema, X as rebindAgentSchema, Y as paginationQuerySchema, Z as refreshTokenSchema, _ as adminUpdateTaskSchema, _t as updateOrganizationSchema, a as AGENT_STATUSES, at as sessionEventMessageSchema, b as agentRuntimeConfigPayloadSchema$1, bt as wsAuthFrameSchema, ct as sessionStateMessageSchema, d as TASK_HEALTH_SIGNALS, dt as updateAdapterConfigSchema, et as scanMentionTokens, f as TASK_STATUSES, ft as updateAgentRuntimeConfigSchema, g as adminCreateTaskSchema, gt as updateMemberSchema, h as addParticipantSchema, ht as updateClientCapabilitiesSchema, i as AGENT_SOURCES, it as sessionCompletionMessageSchema, j as createOrganizationSchema, k as createMemberSchema, l as SYSTEM_CONFIG_DEFAULTS, lt as switchOrgSchema, m as WS_AUTH_FRAME_TIMEOUT_MS, mt as updateChatSchema, n as AGENT_NAME_REGEX$1, nt as sendMessageSchema, o as AGENT_TYPES, ot as sessionEventSchema$1, p as TASK_TERMINAL_STATUSES, pt as updateAgentSchema, q as messageSourceSchema$1, r as AGENT_SELECTOR_HEADER$1, rt as sendToAgentSchema, s as AGENT_VISIBILITY, st as sessionReconcileRequestSchema, t as AGENT_BIND_REJECT_REASONS, tt as selfServiceFeishuBotSchema, u as TASK_CREATOR_TYPES, ut as taskListQuerySchema, v as agentBindRequestSchema, vt as updateSystemConfigSchema, w as connectTokenExchangeSchema, x as agentTypeSchema$1, y as agentPinnedMessageSchema$1, yt as updateTaskStatusSchema, z as githubStartQuerySchema } from "./dist-DUCelK3Z.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-C_zAhB8x-8Khychlu.mjs";
5
6
  import { createRequire } from "node:module";
6
7
  import { ZodError, z } from "zod";
7
8
  import { delimiter, dirname, isAbsolute, join, resolve } from "node:path";
@@ -15,20 +16,21 @@ import { mkdir, writeFile } from "node:fs/promises";
15
16
  import { parse, stringify } from "yaml";
16
17
  import { query } from "@anthropic-ai/claude-agent-sdk";
17
18
  import { execFileSync, execSync, spawn, spawnSync } from "node:child_process";
19
+ import { Codex } from "@openai/codex-sdk";
20
+ import { fileURLToPath } from "node:url";
18
21
  import * as semver from "semver";
19
22
  import bcrypt from "bcrypt";
20
23
  import { and, asc, count, desc, eq, gt, inArray, isNotNull, isNull, lt, ne, or, sql } from "drizzle-orm";
21
24
  import { drizzle } from "drizzle-orm/postgres-js";
22
25
  import postgres from "postgres";
23
26
  import { confirm, input, password, select } from "@inquirer/prompts";
24
- import { fileURLToPath } from "node:url";
25
27
  import { migrate } from "drizzle-orm/postgres-js/migrator";
28
+ import { bigserial, boolean, index, integer, jsonb, pgTable, primaryKey, serial, text, timestamp, unique, uniqueIndex } from "drizzle-orm/pg-core";
26
29
  import cors from "@fastify/cors";
27
30
  import rateLimit from "@fastify/rate-limit";
28
31
  import fastifyStatic from "@fastify/static";
29
32
  import websocket from "@fastify/websocket";
30
33
  import Fastify from "fastify";
31
- import { bigserial, boolean, index, integer, jsonb, pgTable, primaryKey, serial, text, timestamp, unique, uniqueIndex } from "drizzle-orm/pg-core";
32
34
  import { SignJWT, jwtVerify } from "jose";
33
35
  import { Client, EventDispatcher, LoggerLevel, WSClient } from "@larksuiteoapi/node-sdk";
34
36
  //#region ../client/dist/observability-B4kO005X.mjs
@@ -392,7 +394,8 @@ z.enum([
392
394
  "not_owned",
393
395
  "agent_suspended",
394
396
  "wrong_org",
395
- "unknown_agent"
397
+ "unknown_agent",
398
+ "runtime_provider_mismatch"
396
399
  ]);
397
400
  /** Header used on agent-scoped HTTP calls to select which managed agent the JWT acts as. */
398
401
  const AGENT_SELECTOR_HEADER = "x-agent-id";
@@ -420,6 +423,7 @@ z.object({
420
423
  }),
421
424
  clients: z.number().int()
422
425
  });
426
+ const runtimeProviderSchema = z.enum(["claude-code", "codex"]);
423
427
  const agentTypeSchema = z.enum([
424
428
  "human",
425
429
  "personal_assistant",
@@ -461,7 +465,8 @@ z.object({
461
465
  visibility: agentVisibilitySchema.optional(),
462
466
  metadata: z.record(z.string(), z.unknown()).optional(),
463
467
  managerId: z.string().optional(),
464
- clientId: z.string().min(1).max(100).optional()
468
+ clientId: z.string().min(1).max(100).optional(),
469
+ runtimeProvider: runtimeProviderSchema.optional()
465
470
  });
466
471
  z.object({
467
472
  type: agentTypeSchema.optional(),
@@ -472,6 +477,11 @@ z.object({
472
477
  managerId: z.string().nullable().optional(),
473
478
  clientId: z.string().min(1).max(100).nullable().optional()
474
479
  });
480
+ z.object({
481
+ clientId: z.string().min(1).max(100),
482
+ runtimeProvider: runtimeProviderSchema,
483
+ force: z.boolean().optional()
484
+ });
475
485
  z.object({
476
486
  uuid: z.string(),
477
487
  name: z.string().nullable(),
@@ -486,6 +496,7 @@ z.object({
486
496
  metadata: z.record(z.string(), z.unknown()),
487
497
  managerId: z.string().nullable(),
488
498
  clientId: z.string().nullable(),
499
+ runtimeProvider: runtimeProviderSchema,
489
500
  presenceStatus: presenceStatusSchema.optional(),
490
501
  createdAt: z.string(),
491
502
  updatedAt: z.string()
@@ -505,14 +516,16 @@ const agentPinnedMessageSchema = z.object({
505
516
  agentId: z.string(),
506
517
  name: z.string().nullable(),
507
518
  displayName: z.string(),
508
- agentType: agentTypeSchema
519
+ agentType: agentTypeSchema,
520
+ runtimeProvider: runtimeProviderSchema
509
521
  });
510
522
  /**
511
- * Agent runtime configuration — M1 (Claude Code only).
523
+ * Agent runtime configuration.
512
524
  *
513
525
  * Defines the 5 user-tunable field groups that the Hub centrally manages
514
526
  * and pushes down to the client runtime: prompt append, model, MCP servers,
515
- * env vars, and Git repos.
527
+ * env vars, and Git repos. Tagged by `kind` (a runtime provider) so future
528
+ * provider-specific fields can land on a dedicated variant.
516
529
  *
517
530
  * NOTE: do not co-locate with `packages/shared/src/config/` — that namespace
518
531
  * is reserved for the local YAML config (`agent.yaml` / server / client) and
@@ -556,9 +569,11 @@ const gitRepoSchema = z.object({
556
569
  localPath: z.string().min(1).optional()
557
570
  });
558
571
  /**
559
- * Base shape (no refinements) used for `.partial()` derivations such as the
560
- * PATCH payload schema. Zod 4 forbids `.partial()` on a refined object, so we
561
- * keep refinements on a separate full schema below.
572
+ * Untagged base shape 5 user-tunable fields, no `kind` discriminator.
573
+ * Used for `.partial()` derivations on the PATCH side, where `kind` is
574
+ * pinned to `agents.runtime_provider` and never changes via config PATCH.
575
+ * Zod 4 forbids `.partial()` on a refined object, so we keep refinements
576
+ * on the tagged schema below.
562
577
  */
563
578
  const agentRuntimeConfigPayloadShape = z.object({
564
579
  prompt: promptConfigSchema.default({ append: "" }),
@@ -567,6 +582,17 @@ const agentRuntimeConfigPayloadShape = z.object({
567
582
  env: z.array(envEntrySchema).default([]),
568
583
  gitRepos: z.array(gitRepoSchema).default([])
569
584
  });
585
+ /**
586
+ * Tagged variants — read-side, full payload including `kind`. Adding a new
587
+ * provider means adding a variant here, plus a handler factory and a
588
+ * capability probe module on the client side.
589
+ *
590
+ * Provider-specific fields (e.g. codex `sandboxMode`) belong on the
591
+ * matching variant, not on the base shape.
592
+ */
593
+ const claudeRuntimeConfigPayloadShape = agentRuntimeConfigPayloadShape.extend({ kind: z.literal("claude-code") });
594
+ const codexRuntimeConfigPayloadShape = agentRuntimeConfigPayloadShape.extend({ kind: z.literal("codex") });
595
+ const taggedPayloadUnion = z.discriminatedUnion("kind", [claudeRuntimeConfigPayloadShape, codexRuntimeConfigPayloadShape]);
570
596
  const payloadDuplicatesRefinement = (payload, ctx) => {
571
597
  const seenMcp = /* @__PURE__ */ new Set();
572
598
  payload.mcpServers.forEach((server, idx) => {
@@ -611,7 +637,21 @@ const payloadDuplicatesRefinement = (payload, ctx) => {
611
637
  seenPaths.add(path);
612
638
  });
613
639
  };
614
- const agentRuntimeConfigPayloadSchema = agentRuntimeConfigPayloadShape.superRefine(payloadDuplicatesRefinement);
640
+ /**
641
+ * Read-side full payload schema. Rows persisted before 0026 do not carry
642
+ * `kind`; `z.preprocess` injects `"claude-code"` so they parse cleanly into
643
+ * the claude variant. The service layer separately enforces
644
+ * `payload.kind === agents.runtime_provider` on writes.
645
+ */
646
+ const agentRuntimeConfigPayloadSchema = z.preprocess((input) => {
647
+ if (input && typeof input === "object" && !Array.isArray(input) && !("kind" in input)) return {
648
+ ...input,
649
+ kind: "claude-code"
650
+ };
651
+ return input;
652
+ }, taggedPayloadUnion).superRefine((payload, ctx) => {
653
+ payloadDuplicatesRefinement(payload, ctx);
654
+ });
615
655
  const agentRuntimeConfigSchema = z.object({
616
656
  agentId: z.string(),
617
657
  version: z.number().int().positive(),
@@ -720,6 +760,37 @@ z.object({
720
760
  os: z.string().max(50).optional(),
721
761
  sdkVersion: z.string().max(50).optional()
722
762
  });
763
+ const capabilityStateSchema = z.enum([
764
+ "ok",
765
+ "missing",
766
+ "unauthenticated",
767
+ "error"
768
+ ]);
769
+ const capabilityAuthMethodSchema = z.enum([
770
+ "api_key",
771
+ "oauth",
772
+ "auth_json",
773
+ "none"
774
+ ]);
775
+ const capabilityEntrySchema = z.object({
776
+ state: capabilityStateSchema,
777
+ available: z.boolean(),
778
+ authenticated: z.boolean(),
779
+ sdkVersion: z.string().nullable().optional(),
780
+ authMethod: capabilityAuthMethodSchema,
781
+ error: z.string().nullable().optional(),
782
+ detectedAt: z.string()
783
+ });
784
+ /**
785
+ * Capabilities snapshot keyed by runtime provider name. Recorded as a plain
786
+ * `Record<string, CapabilityEntry>` — every entry is optional (a client may
787
+ * report only the runtimes it actually probed) and the key set evolves
788
+ * naturally as new providers ship without a schema migration. Service-layer
789
+ * lookups (`agents.runtime_provider ∈ keys(capabilities)`) treat the keys
790
+ * as `RuntimeProvider` strings.
791
+ */
792
+ const clientCapabilitiesSchema = z.record(z.string(), capabilityEntrySchema);
793
+ z.object({ capabilities: clientCapabilitiesSchema });
723
794
  z.object({
724
795
  limit: z.coerce.number().int().min(1).max(100).default(20),
725
796
  cursor: z.string().optional()
@@ -897,6 +968,39 @@ z.object({
897
968
  ackedAt: z.string().nullable()
898
969
  }).extend({ message: clientMessageSchema });
899
970
  z.object({ limit: z.coerce.number().int().min(1).max(50).default(10) });
971
+ z.object({
972
+ organizationId: z.string(),
973
+ organizationName: z.string(),
974
+ organizationDisplayName: z.string(),
975
+ role: z.string()
976
+ });
977
+ z.object({
978
+ id: z.string(),
979
+ organizationId: z.string(),
980
+ token: z.string(),
981
+ inviteUrl: z.string(),
982
+ role: z.string(),
983
+ createdAt: z.string(),
984
+ expiresAt: z.string().nullable()
985
+ });
986
+ z.object({ token: z.string().min(1) });
987
+ z.object({}).optional();
988
+ z.enum([
989
+ "connect",
990
+ "create_agent",
991
+ "completed"
992
+ ]);
993
+ z.object({
994
+ id: z.string(),
995
+ name: z.string(),
996
+ displayName: z.string(),
997
+ role: z.enum(["admin", "member"])
998
+ });
999
+ z.object({
1000
+ name: z.string().min(2).max(50).regex(/^[a-z0-9][a-z0-9-]*$/),
1001
+ displayName: z.string().min(1).max(200)
1002
+ });
1003
+ z.object({ organizationId: z.string().min(1) });
900
1004
  const memberRoleSchema = z.enum(["admin", "member"]);
901
1005
  const memberSchema = z.object({
902
1006
  id: z.string(),
@@ -953,6 +1057,18 @@ z.object({
953
1057
  read: z.enum(["true", "false"]).transform((v) => v === "true").optional(),
954
1058
  agentId: z.string().optional()
955
1059
  });
1060
+ z.object({ next: z.string().max(256).optional() });
1061
+ z.object({
1062
+ code: z.string().min(1),
1063
+ state: z.string().min(1)
1064
+ });
1065
+ z.object({
1066
+ githubId: z.string().min(1),
1067
+ login: z.string().min(1),
1068
+ email: z.string().email().optional(),
1069
+ displayName: z.string().optional(),
1070
+ next: z.string().max(256).optional()
1071
+ });
956
1072
  const UUID_PATTERN$1 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
957
1073
  z.object({
958
1074
  name: z.string().min(2).max(50).regex(/^[a-z0-9][a-z0-9-]*$/, "Must start with a letter or digit and contain only lowercase alphanumeric and hyphens").refine((v) => !UUID_PATTERN$1.test(v), "Name must not be a UUID format"),
@@ -1330,7 +1446,8 @@ defineConfig({
1330
1446
  },
1331
1447
  server: {
1332
1448
  port: field(z.number().default(8e3), { env: "FIRST_TREE_HUB_PORT" }),
1333
- host: field(z.string().default("127.0.0.1"), { env: "FIRST_TREE_HUB_HOST" })
1449
+ host: field(z.string().default("127.0.0.1"), { env: "FIRST_TREE_HUB_HOST" }),
1450
+ publicUrl: field(z.string().optional(), { env: "FIRST_TREE_HUB_PUBLIC_URL" })
1334
1451
  },
1335
1452
  secrets: {
1336
1453
  jwtSecret: field(z.string(), {
@@ -1358,6 +1475,13 @@ defineConfig({
1358
1475
  }),
1359
1476
  allowedOrg: field(z.string().optional(), { env: "FIRST_TREE_HUB_GITHUB_ALLOWED_ORG" })
1360
1477
  },
1478
+ oauth: optional({ github: optional({
1479
+ clientId: field(z.string(), { env: "FIRST_TREE_HUB_GITHUB_OAUTH_CLIENT_ID" }),
1480
+ clientSecret: field(z.string(), {
1481
+ env: "FIRST_TREE_HUB_GITHUB_OAUTH_CLIENT_SECRET",
1482
+ secret: true
1483
+ })
1484
+ }) }),
1361
1485
  cors: optional({ origin: field(z.string(), { env: "FIRST_TREE_HUB_CORS_ORIGIN" }) }),
1362
1486
  rateLimit: optional({
1363
1487
  max: field(z.number().default(100), { env: "FIRST_TREE_HUB_RATE_LIMIT_MAX" }),
@@ -1481,6 +1605,25 @@ var FirstTreeHubSDK = class {
1481
1605
  async fetchAgentConfig() {
1482
1606
  return this.requestJson("/api/v1/agent/config");
1483
1607
  }
1608
+ /**
1609
+ * Member-scoped: report this client's runtime-provider capabilities. The
1610
+ * server stores them under `clients.metadata.capabilities` after checking
1611
+ * that the connected member owns the client.
1612
+ */
1613
+ async updateCapabilities(clientId, capabilities) {
1614
+ await this.requestVoid(`/api/v1/clients/${encodeURIComponent(clientId)}/capabilities`, {
1615
+ method: "PATCH",
1616
+ body: JSON.stringify({ capabilities })
1617
+ });
1618
+ }
1619
+ /**
1620
+ * Member-scoped: every agent pinned to a client owned by the calling user.
1621
+ * Used by client startup to reconcile the local `agent.yaml::runtime` with
1622
+ * the authoritative `agents.runtime_provider` before spawning handlers.
1623
+ */
1624
+ async listMyAgents() {
1625
+ return this.requestJson("/api/v1/clients/me/agents");
1626
+ }
1484
1627
  async isHubReachable(timeoutMs = 3e3) {
1485
1628
  try {
1486
1629
  const url = `${this._baseUrl}/api/v1/health`;
@@ -1576,7 +1719,7 @@ var SdkError = class extends Error {
1576
1719
  * different organization. The CLI layer detects this via `instanceof` and
1577
1720
  * prompts the user before rotating the local clientId.
1578
1721
  */
1579
- var ClientOrgMismatchError$1 = class extends Error {
1722
+ var ClientOrgMismatchError = class extends Error {
1580
1723
  code = "CLIENT_ORG_MISMATCH";
1581
1724
  constructor(message = "Client belongs to a different organization") {
1582
1725
  super(message);
@@ -1886,7 +2029,7 @@ var ClientConnection = class extends EventEmitter {
1886
2029
  const code = typeof msg.code === "string" ? msg.code : void 0;
1887
2030
  const message = typeof msg.message === "string" ? msg.message : "unknown";
1888
2031
  this.closing = true;
1889
- const err = code === "CLIENT_ORG_MISMATCH" ? new ClientOrgMismatchError$1(message) : /* @__PURE__ */ new Error(`client:register rejected: ${message}`);
2032
+ const err = code === "CLIENT_ORG_MISMATCH" ? new ClientOrgMismatchError(message) : /* @__PURE__ */ new Error(`client:register rejected: ${message}`);
1890
2033
  this.lastHandshakeError = err;
1891
2034
  this.wsLogger.error({
1892
2035
  code,
@@ -2120,13 +2263,23 @@ function getHandlerFactory(type) {
2120
2263
  }
2121
2264
  join(DEFAULT_DATA_DIR, "context-tree");
2122
2265
  /**
2123
- * Bootstrap a workspace with .agent/ directory files.
2266
+ * Marker file written into every workspace so the Codex CLI's project-root
2267
+ * detection (configured via `project_root_markers: ["first-tree-workspace"]`)
2268
+ * stops at the workspace boundary instead of walking up the filesystem and
2269
+ * loading an unintended `AGENTS.md` from the operator's home or repo root.
2270
+ */
2271
+ const FIRST_TREE_WORKSPACE_MARKER = ".first-tree-workspace";
2272
+ /**
2273
+ * Bootstrap a workspace with `.agent/` directory files plus the workspace
2274
+ * root marker (and an optional provider-specific briefing).
2124
2275
  *
2125
- * Writes identity.json, context/self.md (if context tree available), and tools.md.
2126
- * Designed to be called on every handler start() and conditionally on resume().
2276
+ * Writes identity.json, context/agent-instructions.md (if context tree
2277
+ * available), tools.md, the `.first-tree-workspace` marker, and for
2278
+ * Codex — `AGENTS.md`. Idempotent: safe to call on every handler start()
2279
+ * and on resume().
2127
2280
  */
2128
2281
  function bootstrapWorkspace(options) {
2129
- const { workspacePath, identity, contextTreePath, serverUrl, chatId } = options;
2282
+ const { workspacePath, identity, contextTreePath, serverUrl, chatId, briefing } = options;
2130
2283
  const agentDir = join(workspacePath, ".agent");
2131
2284
  const contextDir = join(agentDir, "context");
2132
2285
  if (existsSync(contextDir)) rmSync(contextDir, {
@@ -2152,6 +2305,8 @@ function bootstrapWorkspace(options) {
2152
2305
  if (existsSync(rootNodePath)) copyFileSync(rootNodePath, join(contextDir, "domain-map.md"));
2153
2306
  }
2154
2307
  writeFileSync(join(agentDir, "tools.md"), generateToolsDoc(), "utf-8");
2308
+ writeFileSync(join(workspacePath, FIRST_TREE_WORKSPACE_MARKER), "", "utf-8");
2309
+ if (briefing?.format === "agents-md") writeFileSync(join(workspacePath, "AGENTS.md"), briefing.content, "utf-8");
2155
2310
  }
2156
2311
  function defaultInstallExec(command, args, options) {
2157
2312
  execFileSync(command, args, {
@@ -2877,7 +3032,7 @@ function splitPathExt(pathext) {
2877
3032
  }
2878
3033
  const MAX_RETRIES = 2;
2879
3034
  const TOOL_RESULT_PREVIEW_LIMIT = 400;
2880
- const ASSISTANT_TEXT_EVENT_LIMIT = 8e3;
3035
+ const ASSISTANT_TEXT_EVENT_LIMIT$1 = 8e3;
2881
3036
  const SUPPORTED_IMAGE_MIMES = new Set(SUPPORTED_IMAGE_MIMES$1);
2882
3037
  const MIME_TO_EXT = {
2883
3038
  "image/png": "png",
@@ -3003,7 +3158,7 @@ function createToolCallProcessor(emit) {
3003
3158
  if (text.length === 0) continue;
3004
3159
  emit({
3005
3160
  kind: "assistant_text",
3006
- payload: { text: text.slice(0, ASSISTANT_TEXT_EVENT_LIMIT) }
3161
+ payload: { text: text.slice(0, ASSISTANT_TEXT_EVENT_LIMIT$1) }
3007
3162
  });
3008
3163
  } else if (isThinkingBlock(block)) emit({
3009
3164
  kind: "thinking",
@@ -3547,6 +3702,457 @@ function generateClaudeMd(workspacePath, identity, contextTreePath) {
3547
3702
  }
3548
3703
  writeFileSync(join(workspacePath, "CLAUDE.md"), sections.join("\n"), "utf-8");
3549
3704
  }
3705
+ const ASSISTANT_TEXT_EVENT_LIMIT = 8e3;
3706
+ const RESULT_PREVIEW_LIMIT = 400;
3707
+ /**
3708
+ * Build the per-turn `ThreadOptions` Codex consumes. Exported so unit tests
3709
+ * can lock the auth-mode-friendly defaults (notably `model` only set when
3710
+ * the operator chose one).
3711
+ */
3712
+ function buildCodexThreadOptions(payload, workspaceCwd) {
3713
+ const additionalDirectories = [];
3714
+ for (const repo of payload.gitRepos) {
3715
+ const localPath = repo.localPath ?? deriveRepoLocalPath(repo.url);
3716
+ if (!localPath) continue;
3717
+ additionalDirectories.push(join(workspaceCwd, localPath));
3718
+ }
3719
+ const opts = {
3720
+ workingDirectory: workspaceCwd,
3721
+ skipGitRepoCheck: true,
3722
+ sandboxMode: "workspace-write",
3723
+ approvalPolicy: "never",
3724
+ modelReasoningEffort: "high",
3725
+ webSearchEnabled: false,
3726
+ additionalDirectories
3727
+ };
3728
+ if (payload.model) opts.model = payload.model;
3729
+ return opts;
3730
+ }
3731
+ /**
3732
+ * Codex Handler — session-oriented handler using `@openai/codex-sdk`.
3733
+ *
3734
+ * Each instance owns one Thread for one chat. Each turn is a fresh
3735
+ * `runStreamed()` call (Codex CLI is run-to-completion per turn). Inject
3736
+ * during an active turn buffers messages and runs them as a follow-up turn
3737
+ * the moment the current one completes.
3738
+ *
3739
+ * Key footguns observed end-to-end (private plan §10.7):
3740
+ * - F1: providing `env` to Codex SDK does NOT inherit `process.env`; we
3741
+ * explicitly merge.
3742
+ * - F2: `resumeThread(id)` does NOT inherit `ThreadOptions`; we re-pass
3743
+ * them every time.
3744
+ * - F3: `modelReasoningEffort: "minimal"` is incompatible with default
3745
+ * tools; we default to `"high"` with `webSearchEnabled: false`.
3746
+ * - F6: `Thread` has no close/dispose — shutdown is exclusively
3747
+ * `AbortController.abort()`.
3748
+ */
3749
+ const createCodexHandler = (config) => {
3750
+ const workspaceRoot = config.workspaceRoot;
3751
+ const agentConfigCache = config.agentConfigCache ?? null;
3752
+ const gitMirrorManager = config.gitMirrorManager ?? null;
3753
+ const contextTreePath = config.contextTreePath ?? null;
3754
+ let cwd = null;
3755
+ let codex = null;
3756
+ let thread = null;
3757
+ let threadId = null;
3758
+ let currentAbort = null;
3759
+ let currentTurnPromise = null;
3760
+ let ctx = null;
3761
+ let drainScheduled = false;
3762
+ const queuedMessages = [];
3763
+ const ownedWorktrees = [];
3764
+ function buildEnv(sessionCtx) {
3765
+ const env = {};
3766
+ for (const [k, v] of Object.entries(process.env)) if (typeof v === "string") env[k] = v;
3767
+ const payload = agentConfigCache?.get(sessionCtx.agent.agentId)?.payload;
3768
+ if (payload) for (const e of payload.env) env[e.key] = e.value;
3769
+ const merged = sessionCtx.buildAgentEnv(env);
3770
+ const out = {};
3771
+ for (const [k, v] of Object.entries(merged)) if (typeof v === "string") out[k] = v;
3772
+ return out;
3773
+ }
3774
+ function buildCodexConfig(payload) {
3775
+ const cfg = { project_root_markers: [FIRST_TREE_WORKSPACE_MARKER] };
3776
+ if (payload.mcpServers.length === 0) return cfg;
3777
+ const mcpServers = {};
3778
+ for (const m of payload.mcpServers) if (m.transport === "stdio") mcpServers[m.name] = {
3779
+ command: m.command,
3780
+ args: m.args ?? []
3781
+ };
3782
+ else {
3783
+ const entry = { url: m.url };
3784
+ if (m.headers) entry.headers = m.headers;
3785
+ mcpServers[m.name] = entry;
3786
+ }
3787
+ cfg.mcp_servers = mcpServers;
3788
+ return cfg;
3789
+ }
3790
+ function buildAgentBriefing(payload) {
3791
+ const lines = [];
3792
+ lines.push("# Agent Briefing");
3793
+ lines.push("");
3794
+ if (payload.prompt.append.trim()) {
3795
+ lines.push(payload.prompt.append.trim());
3796
+ lines.push("");
3797
+ }
3798
+ lines.push("Refer to `.agent/identity.json` for your agent identity, `.agent/tools.md` for the");
3799
+ lines.push("first-tree-hub SDK reference, and `.agent/context/` for organisational context");
3800
+ lines.push("(when configured).");
3801
+ return lines.join("\n").concat("\n");
3802
+ }
3803
+ function toCodexInput(message, sessionCtx) {
3804
+ return sessionCtx.formatInboundContent(message).then((text) => text);
3805
+ }
3806
+ async function prepareGitWorktrees(payload, workspaceCwd, chatId) {
3807
+ if (!gitMirrorManager) return;
3808
+ for (const repo of payload.gitRepos) {
3809
+ const localPath = repo.localPath ?? deriveRepoLocalPath(repo.url);
3810
+ if (!localPath) continue;
3811
+ const targetPath = join(workspaceCwd, localPath);
3812
+ if (existsSync(targetPath)) continue;
3813
+ try {
3814
+ await gitMirrorManager.ensureMirror(repo.url);
3815
+ await gitMirrorManager.fetchMirror(repo.url);
3816
+ const result = await gitMirrorManager.createWorktree({
3817
+ url: repo.url,
3818
+ ref: repo.ref,
3819
+ targetPath,
3820
+ sessionKey: chatId
3821
+ });
3822
+ ownedWorktrees.push({
3823
+ url: repo.url,
3824
+ path: targetPath,
3825
+ branchName: result.branchName
3826
+ });
3827
+ } catch (err) {
3828
+ const msg = err instanceof Error ? err.message : String(err);
3829
+ ctx?.log(`codex git materialisation skipped (${repo.url}): ${msg}`);
3830
+ }
3831
+ }
3832
+ }
3833
+ function emitToolCall(sessionCtx, payload) {
3834
+ const event = {
3835
+ kind: "tool_call",
3836
+ payload: {
3837
+ toolUseId: payload.toolUseId,
3838
+ name: payload.name,
3839
+ args: payload.args,
3840
+ status: payload.status,
3841
+ ...payload.resultPreview ? { resultPreview: payload.resultPreview.slice(0, RESULT_PREVIEW_LIMIT) } : {}
3842
+ }
3843
+ };
3844
+ sessionCtx.emitEvent(event);
3845
+ }
3846
+ /**
3847
+ * Translate one terminal `item.completed` payload into the runtime's event
3848
+ * stream and, when the item is the assistant's final message, return the
3849
+ * raw text so `runTurn` can stitch the per-turn reply together.
3850
+ */
3851
+ function processItem(item, sessionCtx) {
3852
+ switch (item.type) {
3853
+ case "agent_message":
3854
+ if (!item.text.trim()) return "";
3855
+ sessionCtx.emitEvent({
3856
+ kind: "assistant_text",
3857
+ payload: { text: item.text.slice(0, ASSISTANT_TEXT_EVENT_LIMIT) }
3858
+ });
3859
+ return item.text;
3860
+ case "command_execution": {
3861
+ const status = item.status === "completed" ? "ok" : item.status === "failed" ? "error" : "pending";
3862
+ emitToolCall(sessionCtx, {
3863
+ toolUseId: item.id,
3864
+ name: "command",
3865
+ args: { command: item.command },
3866
+ status,
3867
+ resultPreview: item.aggregated_output
3868
+ });
3869
+ return "";
3870
+ }
3871
+ case "file_change": {
3872
+ const status = item.status === "completed" ? "ok" : "error";
3873
+ emitToolCall(sessionCtx, {
3874
+ toolUseId: item.id,
3875
+ name: "file_change",
3876
+ args: { changes: item.changes },
3877
+ status
3878
+ });
3879
+ return "";
3880
+ }
3881
+ case "mcp_tool_call": {
3882
+ const status = item.status === "completed" ? "ok" : item.status === "failed" ? "error" : "pending";
3883
+ const resultPreview = item.error ? `error: ${item.error.message}` : item.result ? JSON.stringify(item.result.structured_content ?? item.result.content) : void 0;
3884
+ emitToolCall(sessionCtx, {
3885
+ toolUseId: item.id,
3886
+ name: `mcp:${item.server}/${item.tool}`,
3887
+ args: item.arguments,
3888
+ status,
3889
+ resultPreview
3890
+ });
3891
+ return "";
3892
+ }
3893
+ case "web_search":
3894
+ emitToolCall(sessionCtx, {
3895
+ toolUseId: item.id,
3896
+ name: "web_search",
3897
+ args: { query: item.query },
3898
+ status: "ok"
3899
+ });
3900
+ return "";
3901
+ case "todo_list":
3902
+ emitToolCall(sessionCtx, {
3903
+ toolUseId: item.id,
3904
+ name: "todo_list",
3905
+ args: { items: item.items },
3906
+ status: "ok"
3907
+ });
3908
+ return "";
3909
+ case "reasoning":
3910
+ sessionCtx.emitEvent({
3911
+ kind: "thinking",
3912
+ payload: {}
3913
+ });
3914
+ return "";
3915
+ case "error":
3916
+ sessionCtx.emitEvent({
3917
+ kind: "error",
3918
+ payload: {
3919
+ source: "tool",
3920
+ message: item.message
3921
+ }
3922
+ });
3923
+ return "";
3924
+ default: return "";
3925
+ }
3926
+ }
3927
+ async function runTurn(input, sessionCtx) {
3928
+ const activeThread = thread;
3929
+ if (!activeThread) return;
3930
+ const abort = new AbortController();
3931
+ currentAbort = abort;
3932
+ sessionCtx.setRuntimeState("working");
3933
+ const assistantTexts = [];
3934
+ let turnFailed = false;
3935
+ const promise = (async () => {
3936
+ try {
3937
+ const streamed = await activeThread.runStreamed(input, { signal: abort.signal });
3938
+ for await (const event of streamed.events) {
3939
+ if (abort.signal.aborted) break;
3940
+ sessionCtx.touch();
3941
+ if (event.type === "thread.started") threadId = event.thread_id;
3942
+ else if (event.type === "turn.started") {} else if (event.type === "item.completed") {
3943
+ const text = processItem(event.item, sessionCtx);
3944
+ if (text) assistantTexts.push(text);
3945
+ } else if (event.type === "item.started" || event.type === "item.updated") {} else if (event.type === "turn.completed") {} else if (event.type === "turn.failed") {
3946
+ turnFailed = true;
3947
+ sessionCtx.emitEvent({
3948
+ kind: "error",
3949
+ payload: {
3950
+ source: "sdk",
3951
+ message: event.error.message
3952
+ }
3953
+ });
3954
+ } else if (event.type === "error") sessionCtx.emitEvent({
3955
+ kind: "error",
3956
+ payload: {
3957
+ source: "sdk",
3958
+ message: event.message
3959
+ }
3960
+ });
3961
+ }
3962
+ } catch (err) {
3963
+ if (abort.signal.aborted) return;
3964
+ turnFailed = true;
3965
+ const msg = err instanceof Error ? err.message : String(err);
3966
+ sessionCtx.emitEvent({
3967
+ kind: "error",
3968
+ payload: {
3969
+ source: "sdk",
3970
+ message: msg
3971
+ }
3972
+ });
3973
+ }
3974
+ })();
3975
+ currentTurnPromise = promise;
3976
+ try {
3977
+ await promise;
3978
+ } finally {
3979
+ currentAbort = null;
3980
+ currentTurnPromise = null;
3981
+ }
3982
+ if (abort.signal.aborted) return;
3983
+ const accumulated = assistantTexts.join("\n\n");
3984
+ let forwardFailed = false;
3985
+ if (accumulated.trim()) try {
3986
+ await sessionCtx.forwardResult(accumulated);
3987
+ } catch (err) {
3988
+ forwardFailed = true;
3989
+ const msg = err instanceof Error ? err.message : String(err);
3990
+ sessionCtx.emitEvent({
3991
+ kind: "error",
3992
+ payload: {
3993
+ source: "runtime",
3994
+ message: `forwardResult failed: ${msg}`
3995
+ }
3996
+ });
3997
+ }
3998
+ const succeeded = !turnFailed && !forwardFailed;
3999
+ sessionCtx.emitEvent({
4000
+ kind: "turn_end",
4001
+ payload: { status: succeeded ? "success" : "error" }
4002
+ });
4003
+ if (succeeded && accumulated.trim()) sessionCtx.reportSessionCompletion();
4004
+ sessionCtx.setRuntimeState("idle");
4005
+ if (queuedMessages.length > 0 && !drainScheduled) {
4006
+ drainScheduled = true;
4007
+ setImmediate(() => {
4008
+ drainScheduled = false;
4009
+ const drained = queuedMessages.splice(0);
4010
+ if (drained.length === 0 || !ctx || !thread) return;
4011
+ mergeAndRun(drained, ctx);
4012
+ });
4013
+ }
4014
+ }
4015
+ async function mergeAndRun(drained, sessionCtx) {
4016
+ const inputs = [];
4017
+ for (const m of drained) try {
4018
+ inputs.push(await sessionCtx.formatInboundContent(m));
4019
+ } catch (err) {
4020
+ sessionCtx.log(`codex inject formatInboundContent failed: ${err instanceof Error ? err.message : String(err)}`);
4021
+ }
4022
+ if (inputs.length === 0) return;
4023
+ await runTurn(inputs.join("\n\n"), sessionCtx);
4024
+ }
4025
+ return {
4026
+ async start(message, sessionCtx) {
4027
+ ctx = sessionCtx;
4028
+ cwd = await acquireWorkspace(workspaceRoot, sessionCtx.chatId);
4029
+ let payload = null;
4030
+ if (agentConfigCache) payload = (await agentConfigCache.refresh(sessionCtx.agent.agentId)).payload;
4031
+ if (!payload) payload = {
4032
+ kind: "codex",
4033
+ prompt: { append: "" },
4034
+ model: "",
4035
+ mcpServers: [],
4036
+ env: [],
4037
+ gitRepos: []
4038
+ };
4039
+ bootstrapWorkspace({
4040
+ workspacePath: cwd,
4041
+ identity: sessionCtx.agent,
4042
+ contextTreePath,
4043
+ serverUrl: sessionCtx.sdk.serverUrl,
4044
+ chatId: sessionCtx.chatId,
4045
+ briefing: {
4046
+ format: "agents-md",
4047
+ content: buildAgentBriefing(payload)
4048
+ }
4049
+ });
4050
+ await prepareGitWorktrees(payload, cwd, sessionCtx.chatId);
4051
+ codex = new Codex({
4052
+ env: buildEnv(sessionCtx),
4053
+ config: buildCodexConfig(payload)
4054
+ });
4055
+ thread = codex.startThread(buildCodexThreadOptions(payload, cwd));
4056
+ await runTurn(await toCodexInput(message, sessionCtx), sessionCtx);
4057
+ if (!threadId) threadId = thread.id ?? null;
4058
+ if (!threadId) throw new Error("codex did not assign a thread id during the first turn");
4059
+ return threadId;
4060
+ },
4061
+ async resume(message, sessionId, sessionCtx) {
4062
+ ctx = sessionCtx;
4063
+ cwd = await acquireWorkspace(workspaceRoot, sessionCtx.chatId);
4064
+ let payload = null;
4065
+ if (agentConfigCache) payload = (await agentConfigCache.refresh(sessionCtx.agent.agentId)).payload;
4066
+ if (!payload) payload = {
4067
+ kind: "codex",
4068
+ prompt: { append: "" },
4069
+ model: "",
4070
+ mcpServers: [],
4071
+ env: [],
4072
+ gitRepos: []
4073
+ };
4074
+ bootstrapWorkspace({
4075
+ workspacePath: cwd,
4076
+ identity: sessionCtx.agent,
4077
+ contextTreePath,
4078
+ serverUrl: sessionCtx.sdk.serverUrl,
4079
+ chatId: sessionCtx.chatId,
4080
+ briefing: {
4081
+ format: "agents-md",
4082
+ content: buildAgentBriefing(payload)
4083
+ }
4084
+ });
4085
+ await prepareGitWorktrees(payload, cwd, sessionCtx.chatId);
4086
+ codex = new Codex({
4087
+ env: buildEnv(sessionCtx),
4088
+ config: buildCodexConfig(payload)
4089
+ });
4090
+ thread = codex.resumeThread(sessionId, buildCodexThreadOptions(payload, cwd));
4091
+ threadId = sessionId;
4092
+ if (message) await runTurn(await toCodexInput(message, sessionCtx), sessionCtx);
4093
+ return sessionId;
4094
+ },
4095
+ inject(message) {
4096
+ if (currentTurnPromise) {
4097
+ queuedMessages.push(message);
4098
+ return;
4099
+ }
4100
+ const sessionCtx = ctx;
4101
+ if (!sessionCtx || !thread) return;
4102
+ (async () => {
4103
+ try {
4104
+ await runTurn(await toCodexInput(message, sessionCtx), sessionCtx);
4105
+ } catch (err) {
4106
+ sessionCtx.log(`codex inject failed: ${err instanceof Error ? err.message : String(err)}`);
4107
+ }
4108
+ })();
4109
+ },
4110
+ async suspend() {
4111
+ currentAbort?.abort();
4112
+ try {
4113
+ await currentTurnPromise;
4114
+ } catch {}
4115
+ currentAbort = null;
4116
+ currentTurnPromise = null;
4117
+ thread = null;
4118
+ codex = null;
4119
+ },
4120
+ async shutdown() {
4121
+ currentAbort?.abort();
4122
+ try {
4123
+ await currentTurnPromise;
4124
+ } catch {}
4125
+ currentAbort = null;
4126
+ currentTurnPromise = null;
4127
+ thread = null;
4128
+ codex = null;
4129
+ if (gitMirrorManager) {
4130
+ for (const wt of ownedWorktrees) try {
4131
+ await gitMirrorManager.removeWorktree({
4132
+ url: wt.url,
4133
+ path: wt.path,
4134
+ branchName: wt.branchName
4135
+ });
4136
+ } catch (err) {
4137
+ ctx?.log(`codex worktree cleanup failed (${wt.path}): ${err instanceof Error ? err.message : String(err)}`);
4138
+ }
4139
+ ownedWorktrees.length = 0;
4140
+ }
4141
+ if (cwd && existsSync(cwd)) try {
4142
+ rmSync(cwd, {
4143
+ recursive: true,
4144
+ force: true
4145
+ });
4146
+ } catch (err) {
4147
+ ctx?.log(`codex workspace cleanup failed: ${err instanceof Error ? err.message : String(err)}`);
4148
+ }
4149
+ cwd = null;
4150
+ threadId = null;
4151
+ ctx = null;
4152
+ queuedMessages.length = 0;
4153
+ }
4154
+ };
4155
+ };
3550
4156
  /** Register all built-in handlers. Call once at startup. */
3551
4157
  function registerBuiltinHandlers() {
3552
4158
  const resolution = resolveClaudeCodeExecutable();
@@ -3556,6 +4162,7 @@ function registerBuiltinHandlers() {
3556
4162
  ...config,
3557
4163
  claudeCodeExecutable: resolution.path
3558
4164
  }));
4165
+ registerHandler("codex", (config) => createCodexHandler(config));
3559
4166
  }
3560
4167
  function createAgentConfigCache(opts) {
3561
4168
  const { sdk } = opts;
@@ -3590,6 +4197,7 @@ function createAgentConfigCache(opts) {
3590
4197
  agentId,
3591
4198
  version: 0,
3592
4199
  payload: {
4200
+ kind: "claude-code",
3593
4201
  prompt: { append: "" },
3594
4202
  model: "",
3595
4203
  mcpServers: [],
@@ -4607,50 +5215,270 @@ var AgentSlot = class {
4607
5215
  }
4608
5216
  };
4609
5217
  /**
4610
- * Runtime-wide constants (Step 7 + Step 11).
4611
- *
4612
- * After Step 11 these values are fixed at code level — the previously
4613
- * exposed `session.idle_timeout` / `session.max_sessions` / `concurrency`
4614
- * fields in `agent.yaml` are dropped per PRD §D1 / §D15.
4615
- *
4616
- * Picked deliberately wide for M1 internal use; revisit when scale shows
4617
- * a real bottleneck.
5218
+ * Top-level marker file Claude Code writes after a successful OAuth login.
5219
+ * Path is platform-agnostic (`~/.claude.json`); the access token itself lives
5220
+ * in the platform credential store (macOS Keychain entry "Claude Code-
5221
+ * credentials", or libsecret on Linux), so we treat the presence of an
5222
+ * `oauthAccount.accountUuid` field as the canonical "logged in" signal.
4618
5223
  */
4619
- /** 8 hours covers "leave it on overnight" usage without holding worktrees forever. */
4620
- const IDLE_TIMEOUT_MS = 480 * 60 * 1e3;
4621
- z.object({
4622
- idle_timeout: z.number().int().positive().optional(),
4623
- max_sessions: z.number().int().positive().optional()
4624
- }).passthrough();
4625
- const sessionConfigSchema = z.object({
4626
- idle_timeout: z.number().int().positive().default(IDLE_TIMEOUT_MS / 1e3),
4627
- max_sessions: z.number().int().positive().default(50),
4628
- reconcile_interval_seconds: z.number().int().min(30).max(3600).default(300)
4629
- }).passthrough();
4630
- const agentSlotConfigSchema = z.object({
4631
- agentId: z.string().min(1),
4632
- type: z.string().min(1),
4633
- session: sessionConfigSchema.prefault({}),
4634
- concurrency: z.number().int().positive().default(16)
4635
- }).passthrough();
4636
- z.object({
4637
- server: z.url().default("http://localhost:8000"),
4638
- agents: z.record(z.string(), agentSlotConfigSchema).refine((agents) => Object.keys(agents).length > 0, "At least one agent must be defined")
4639
- });
5224
+ const CLAUDE_PROFILE_PATH = () => join(homedir(), ".claude.json");
5225
+ function hasClaudeOAuthAccount() {
5226
+ try {
5227
+ const path = CLAUDE_PROFILE_PATH();
5228
+ if (!existsSync(path)) return false;
5229
+ const raw = readFileSync(path, "utf-8");
5230
+ const obj = JSON.parse(raw);
5231
+ return typeof obj.oauthAccount?.accountUuid === "string" && obj.oauthAccount.accountUuid.length > 0;
5232
+ } catch {
5233
+ return false;
5234
+ }
5235
+ }
5236
+ async function readSdkVersion$1() {
5237
+ try {
5238
+ let dir = dirname(fileURLToPath(await import.meta.resolve("@anthropic-ai/claude-agent-sdk")));
5239
+ for (let depth = 0; depth < 8; depth += 1) {
5240
+ const candidate = join(dir, "package.json");
5241
+ if (existsSync(candidate)) {
5242
+ const pkg = JSON.parse(readFileSync(candidate, "utf-8"));
5243
+ if (pkg.name === "@anthropic-ai/claude-agent-sdk" && typeof pkg.version === "string") return pkg.version;
5244
+ }
5245
+ const parent = dirname(dir);
5246
+ if (parent === dir) break;
5247
+ dir = parent;
5248
+ }
5249
+ } catch {}
5250
+ return null;
5251
+ }
5252
+ function detectAuth$1() {
5253
+ if (process.env.ANTHROPIC_API_KEY && process.env.ANTHROPIC_API_KEY.length > 0) return {
5254
+ authenticated: true,
5255
+ method: "api_key"
5256
+ };
5257
+ if (hasClaudeOAuthAccount()) return {
5258
+ authenticated: true,
5259
+ method: "oauth"
5260
+ };
5261
+ return {
5262
+ authenticated: false,
5263
+ method: "none"
5264
+ };
5265
+ }
4640
5266
  /**
4641
- * Version-drift decision flow. Install, prompt, and exit are delegated to
4642
- * command-layer callbacks so the Client package stays free of CLI /
4643
- * filesystem knowledge.
5267
+ * Probe whether the Claude Code runtime is usable on this machine.
5268
+ *
5269
+ * `state` is the authoritative field; `available` and `authenticated` are
5270
+ * derived booleans kept around for simple consumers (e.g. capability lookup
5271
+ * in service-layer guards).
4644
5272
  */
4645
- var UpdateManager = class UpdateManager {
4646
- options;
4647
- connection;
4648
- welcomeListener;
4649
- updateInFlight = false;
4650
- quietGateTimer = null;
4651
- disposed = false;
4652
- /**
4653
- * Set when a standalone (unmanaged) executeUpdate reports `installed: true`
5273
+ async function probeClaudeCodeCapability() {
5274
+ const detectedAt = (/* @__PURE__ */ new Date()).toISOString();
5275
+ try {
5276
+ let sdkPresent = false;
5277
+ try {
5278
+ await import("@anthropic-ai/claude-agent-sdk");
5279
+ sdkPresent = true;
5280
+ } catch {
5281
+ sdkPresent = false;
5282
+ }
5283
+ if (!sdkPresent) return {
5284
+ state: "missing",
5285
+ available: false,
5286
+ authenticated: false,
5287
+ sdkVersion: null,
5288
+ authMethod: "none",
5289
+ detectedAt
5290
+ };
5291
+ const sdkVersion = await readSdkVersion$1();
5292
+ const auth = detectAuth$1();
5293
+ if (!auth.authenticated) return {
5294
+ state: "unauthenticated",
5295
+ available: true,
5296
+ authenticated: false,
5297
+ sdkVersion,
5298
+ authMethod: "none",
5299
+ detectedAt
5300
+ };
5301
+ return {
5302
+ state: "ok",
5303
+ available: true,
5304
+ authenticated: true,
5305
+ sdkVersion,
5306
+ authMethod: auth.method,
5307
+ detectedAt
5308
+ };
5309
+ } catch (err) {
5310
+ return {
5311
+ state: "error",
5312
+ available: false,
5313
+ authenticated: false,
5314
+ sdkVersion: null,
5315
+ authMethod: "none",
5316
+ error: err instanceof Error ? err.message : String(err),
5317
+ detectedAt
5318
+ };
5319
+ }
5320
+ }
5321
+ function codexAuthPath() {
5322
+ return join(process.env.CODEX_HOME ?? join(homedir(), ".codex"), "auth.json");
5323
+ }
5324
+ async function readSdkVersion() {
5325
+ try {
5326
+ let dir = dirname(fileURLToPath(await import.meta.resolve("@openai/codex-sdk")));
5327
+ for (let depth = 0; depth < 8; depth += 1) {
5328
+ const candidate = join(dir, "package.json");
5329
+ if (existsSync(candidate)) {
5330
+ const pkg = JSON.parse(readFileSync(candidate, "utf-8"));
5331
+ if (pkg.name === "@openai/codex-sdk" && typeof pkg.version === "string") return pkg.version;
5332
+ }
5333
+ const parent = dirname(dir);
5334
+ if (parent === dir) break;
5335
+ dir = parent;
5336
+ }
5337
+ } catch {}
5338
+ return null;
5339
+ }
5340
+ function detectAuth() {
5341
+ if (process.env.CODEX_API_KEY && process.env.CODEX_API_KEY.length > 0) return {
5342
+ authenticated: true,
5343
+ method: "api_key"
5344
+ };
5345
+ if (existsSync(codexAuthPath())) return {
5346
+ authenticated: true,
5347
+ method: "auth_json"
5348
+ };
5349
+ return {
5350
+ authenticated: false,
5351
+ method: "none"
5352
+ };
5353
+ }
5354
+ /**
5355
+ * Probe whether the OpenAI Codex runtime is usable on this machine.
5356
+ * Treats `~/.codex/auth.json` (set by `codex login`) as the canonical local
5357
+ * auth source; CODEX_API_KEY env shortcuts that for ephemeral use.
5358
+ */
5359
+ async function probeCodexCapability() {
5360
+ const detectedAt = (/* @__PURE__ */ new Date()).toISOString();
5361
+ try {
5362
+ let sdkPresent = false;
5363
+ try {
5364
+ await import("@openai/codex-sdk");
5365
+ sdkPresent = true;
5366
+ } catch {
5367
+ sdkPresent = false;
5368
+ }
5369
+ if (!sdkPresent) return {
5370
+ state: "missing",
5371
+ available: false,
5372
+ authenticated: false,
5373
+ sdkVersion: null,
5374
+ authMethod: "none",
5375
+ detectedAt
5376
+ };
5377
+ const sdkVersion = await readSdkVersion();
5378
+ const auth = detectAuth();
5379
+ if (!auth.authenticated) return {
5380
+ state: "unauthenticated",
5381
+ available: true,
5382
+ authenticated: false,
5383
+ sdkVersion,
5384
+ authMethod: "none",
5385
+ detectedAt
5386
+ };
5387
+ return {
5388
+ state: "ok",
5389
+ available: true,
5390
+ authenticated: true,
5391
+ sdkVersion,
5392
+ authMethod: auth.method,
5393
+ detectedAt
5394
+ };
5395
+ } catch (err) {
5396
+ return {
5397
+ state: "error",
5398
+ available: false,
5399
+ authenticated: false,
5400
+ sdkVersion: null,
5401
+ authMethod: "none",
5402
+ error: err instanceof Error ? err.message : String(err),
5403
+ detectedAt
5404
+ };
5405
+ }
5406
+ }
5407
+ /**
5408
+ * Run every built-in capability probe and aggregate the results.
5409
+ *
5410
+ * Each provider gets its own module under this directory; the orchestrator
5411
+ * is intentionally simple — adding a new provider means importing the new
5412
+ * probe here and registering its key. The probe modules themselves are
5413
+ * deliberately not part of the `HandlerFactory` interface so capability
5414
+ * detection stays decoupled from runtime instantiation (so we can probe
5415
+ * whether a runtime is usable before spawning anything).
5416
+ */
5417
+ async function probeCapabilities() {
5418
+ const probes = [["claude-code", probeClaudeCodeCapability()], ["codex", probeCodexCapability()]];
5419
+ const out = {};
5420
+ await Promise.all(probes.map(async ([provider, p]) => {
5421
+ try {
5422
+ out[provider] = await p;
5423
+ } catch (err) {
5424
+ out[provider] = {
5425
+ state: "error",
5426
+ available: false,
5427
+ authenticated: false,
5428
+ sdkVersion: null,
5429
+ authMethod: "none",
5430
+ error: err instanceof Error ? err.message : String(err),
5431
+ detectedAt: (/* @__PURE__ */ new Date()).toISOString()
5432
+ };
5433
+ }
5434
+ }));
5435
+ return out;
5436
+ }
5437
+ /**
5438
+ * Runtime-wide constants (Step 7 + Step 11).
5439
+ *
5440
+ * After Step 11 these values are fixed at code level — the previously
5441
+ * exposed `session.idle_timeout` / `session.max_sessions` / `concurrency`
5442
+ * fields in `agent.yaml` are dropped per PRD §D1 / §D15.
5443
+ *
5444
+ * Picked deliberately wide for M1 internal use; revisit when scale shows
5445
+ * a real bottleneck.
5446
+ */
5447
+ /** 8 hours — covers "leave it on overnight" usage without holding worktrees forever. */
5448
+ const IDLE_TIMEOUT_MS = 480 * 60 * 1e3;
5449
+ z.object({
5450
+ idle_timeout: z.number().int().positive().optional(),
5451
+ max_sessions: z.number().int().positive().optional()
5452
+ }).passthrough();
5453
+ const sessionConfigSchema = z.object({
5454
+ idle_timeout: z.number().int().positive().default(IDLE_TIMEOUT_MS / 1e3),
5455
+ max_sessions: z.number().int().positive().default(50),
5456
+ reconcile_interval_seconds: z.number().int().min(30).max(3600).default(300)
5457
+ }).passthrough();
5458
+ const agentSlotConfigSchema = z.object({
5459
+ agentId: z.string().min(1),
5460
+ type: z.string().min(1),
5461
+ session: sessionConfigSchema.prefault({}),
5462
+ concurrency: z.number().int().positive().default(16)
5463
+ }).passthrough();
5464
+ z.object({
5465
+ server: z.url().default("http://localhost:8000"),
5466
+ agents: z.record(z.string(), agentSlotConfigSchema).refine((agents) => Object.keys(agents).length > 0, "At least one agent must be defined")
5467
+ });
5468
+ /**
5469
+ * Version-drift decision flow. Install, prompt, and exit are delegated to
5470
+ * command-layer callbacks so the Client package stays free of CLI /
5471
+ * filesystem knowledge.
5472
+ */
5473
+ var UpdateManager = class UpdateManager {
5474
+ options;
5475
+ connection;
5476
+ welcomeListener;
5477
+ updateInFlight = false;
5478
+ quietGateTimer = null;
5479
+ disposed = false;
5480
+ /**
5481
+ * Set when a standalone (unmanaged) executeUpdate reports `installed: true`
4654
5482
  * without exiting. The new bits are on disk; subsequent welcome frames must
4655
5483
  * not re-invoke npm since a restart is the only way to pick them up.
4656
5484
  */
@@ -4802,7 +5630,7 @@ function result(data) {
4802
5630
  data
4803
5631
  })}\n`);
4804
5632
  }
4805
- function fail(code, message, exitCode = 1) {
5633
+ function fail$1(code, message, exitCode = 1) {
4806
5634
  process.stderr.write(`${JSON.stringify({
4807
5635
  ok: false,
4808
5636
  error: {
@@ -4837,13 +5665,26 @@ function line(text) {
4837
5665
  }
4838
5666
  const print = {
4839
5667
  result,
4840
- fail,
5668
+ fail: fail$1,
4841
5669
  status,
4842
5670
  check,
4843
5671
  blank,
4844
5672
  line
4845
5673
  };
4846
5674
  //#endregion
5675
+ //#region src/cli/output.ts
5676
+ /**
5677
+ * CLI output re-exports. The underlying implementation lives in
5678
+ * `core/output.ts` (the Print layer). Keep these thin wrappers so callers that
5679
+ * only depend on `cli/output.ts` keep working during the migration.
5680
+ */
5681
+ function success(data) {
5682
+ print.result(data);
5683
+ }
5684
+ function fail(code, message, exitCode = 1) {
5685
+ return print.fail(code, message, exitCode);
5686
+ }
5687
+ //#endregion
4847
5688
  //#region src/core/agent-messaging.ts
4848
5689
  /**
4849
5690
  * Resolve `replyTo` envelope fields for `agent send`. When the CLI is invoked
@@ -5217,7 +6058,7 @@ var ClientRuntime = class {
5217
6058
  });
5218
6059
  const yaml = stringify({
5219
6060
  agentId: message.agentId,
5220
- runtime: "claude-code"
6061
+ runtime: message.runtimeProvider
5221
6062
  });
5222
6063
  writeFileSync(join(agentDir, "agent.yaml"), yaml, { mode: 384 });
5223
6064
  print.check(true, `auto-added agent "${localName}"`, `${message.agentId} (from server push)`);
@@ -6530,7 +7371,7 @@ async function onboardCreate(args) {
6530
7371
  }
6531
7372
  const runtimeAgent = args.type === "human" ? args.assistant : args.id;
6532
7373
  if (args.feishuBotAppId && args.feishuBotAppSecret) {
6533
- const { bindFeishuBot } = await import("./feishu-DEmwoNn_.mjs").then((n) => n.r);
7374
+ const { bindFeishuBot } = await import("./feishu-Boy3n8CT.mjs").then((n) => n.r);
6534
7375
  const targetAgentUuid = args.type === "human" ? assistantUuid : primary.uuid;
6535
7376
  if (!targetAgentUuid) print.line(`Warning: Cannot bind Feishu bot — no runtime agent available for "${args.id}".\n`);
6536
7377
  else {
@@ -6693,6 +7534,66 @@ function setNestedByDot(obj, dotPath, value) {
6693
7534
  if (lastKey !== void 0) current[lastKey] = value;
6694
7535
  }
6695
7536
  //#endregion
7537
+ //#region src/core/runtime-provider-reconcile.ts
7538
+ /**
7539
+ * Pre-flight reconciliation called before the agents loop spawns. Pulls
7540
+ * authoritative `runtime_provider` for every agent the calling user owns and
7541
+ * rewrites any local `agent.yaml` whose `runtime` field disagrees. Best-
7542
+ * effort: a transient hub failure logs and falls back to the local YAML
7543
+ * value (the in-band repair path catches any remaining drift on first bind).
7544
+ */
7545
+ async function reconcileLocalRuntimeProviders(opts) {
7546
+ const res = await fetch(`${opts.serverUrl}/api/v1/clients/me/agents`, { headers: { Authorization: `Bearer ${opts.accessToken}` } });
7547
+ if (!res.ok) throw new Error(`hub returned ${res.status} on /clients/me/agents`);
7548
+ const items = await res.json();
7549
+ const byAgentId = new Map(items.map((it) => [it.agentId, it]));
7550
+ if (!existsSync(opts.agentsDir)) return;
7551
+ const subdirs = readdirSync(opts.agentsDir, { withFileTypes: true }).filter((d) => d.isDirectory());
7552
+ for (const subdir of subdirs) {
7553
+ const yamlPath = join(opts.agentsDir, subdir.name, "agent.yaml");
7554
+ if (!existsSync(yamlPath)) continue;
7555
+ let parsed;
7556
+ try {
7557
+ parsed = parse(readFileSync(yamlPath, "utf-8")) ?? {};
7558
+ } catch (err) {
7559
+ const msg = err instanceof Error ? err.message : String(err);
7560
+ opts.log?.("warn", `agent ${subdir.name}: cannot parse yaml — ${msg}`);
7561
+ continue;
7562
+ }
7563
+ if (!parsed.agentId) continue;
7564
+ const auth = byAgentId.get(parsed.agentId);
7565
+ if (!auth) continue;
7566
+ if (parsed.runtime === auth.runtimeProvider) continue;
7567
+ const next = {
7568
+ ...parsed,
7569
+ runtime: auth.runtimeProvider
7570
+ };
7571
+ try {
7572
+ writeFileSync(yamlPath, stringify(next), { mode: 384 });
7573
+ opts.log?.("info", `agent ${parsed.agentId}: yaml runtime "${parsed.runtime ?? "(unset)"}" → "${auth.runtimeProvider}" (hub authoritative)`);
7574
+ } catch (err) {
7575
+ const msg = err instanceof Error ? err.message : String(err);
7576
+ opts.log?.("warn", `agent ${parsed.agentId}: failed to rewrite yaml — ${msg}`);
7577
+ }
7578
+ }
7579
+ }
7580
+ /**
7581
+ * Member-scoped capabilities upload. Server stores the snapshot under
7582
+ * `clients.metadata.capabilities`. Best-effort: failure does not block
7583
+ * client startup since capabilities only matter for UI / admin checks.
7584
+ */
7585
+ async function uploadClientCapabilities(opts) {
7586
+ const res = await fetch(`${opts.serverUrl}/api/v1/clients/${encodeURIComponent(opts.clientId)}/capabilities`, {
7587
+ method: "PATCH",
7588
+ headers: {
7589
+ Authorization: `Bearer ${opts.accessToken}`,
7590
+ "Content-Type": "application/json"
7591
+ },
7592
+ body: JSON.stringify({ capabilities: opts.capabilities })
7593
+ });
7594
+ if (!res.ok) throw new Error(`hub returned ${res.status} on PATCH /clients/${opts.clientId}/capabilities`);
7595
+ }
7596
+ //#endregion
6696
7597
  //#region ../../node_modules/.pnpm/hearback-core@0.1.1/node_modules/hearback-core/dist/schema.js
6697
7598
  const FeedbackType = z.enum(["bug", "feature"]);
6698
7599
  const BrowserContext = z.object({
@@ -7450,7 +8351,7 @@ function createFeedbackHandler(config) {
7450
8351
  return { handle };
7451
8352
  }
7452
8353
  //#endregion
7453
- //#region ../server/dist/app-DugUZNsw.mjs
8354
+ //#region ../server/dist/app-D-aIvdiQ.mjs
7454
8355
  var __defProp = Object.defineProperty;
7455
8356
  var __exportAll = (all, no_symbols) => {
7456
8357
  let target = {};
@@ -7461,28 +8362,6 @@ var __exportAll = (all, no_symbols) => {
7461
8362
  if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
7462
8363
  return target;
7463
8364
  };
7464
- /** Organization entity. Agents and chats belong to exactly one organization. */
7465
- const organizations = pgTable("organizations", {
7466
- id: text("id").primaryKey(),
7467
- name: text("name").unique().notNull(),
7468
- displayName: text("display_name").notNull(),
7469
- maxAgents: integer("max_agents").notNull().default(0),
7470
- maxMessagesPerMinute: integer("max_messages_per_minute").notNull().default(0),
7471
- features: jsonb("features").$type().notNull().default({}),
7472
- createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
7473
- updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
7474
- });
7475
- /** User accounts. Passwords are stored as bcrypt hashes. */
7476
- const users = pgTable("users", {
7477
- id: text("id").primaryKey(),
7478
- username: text("username").unique().notNull(),
7479
- passwordHash: text("password_hash").notNull(),
7480
- displayName: text("display_name").notNull(),
7481
- avatarUrl: text("avatar_url"),
7482
- status: text("status").notNull().default("active"),
7483
- createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
7484
- updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
7485
- });
7486
8365
  /**
7487
8366
  * Client connections. A client is a single SDK process (AgentRuntime) that may
7488
8367
  * host multiple agents. From the unified-user-token milestone on, a client is
@@ -7526,6 +8405,7 @@ const agents = pgTable("agents", {
7526
8405
  metadata: jsonb("metadata").$type().notNull().default({}),
7527
8406
  managerId: text("manager_id").notNull(),
7528
8407
  clientId: text("client_id").references(() => clients.id, { onDelete: "restrict" }),
8408
+ runtimeProvider: text("runtime_provider").notNull().default("claude-code"),
7529
8409
  createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
7530
8410
  updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
7531
8411
  }, (table) => [
@@ -7546,57 +8426,6 @@ const adapterAgentMappings = pgTable("adapter_agent_mappings", {
7546
8426
  metadata: jsonb("metadata").$type().notNull().default({}),
7547
8427
  createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
7548
8428
  }, (table) => [unique("uq_adapter_agent_mapping").on(table.platform, table.externalUserId)]);
7549
- var AppError = class extends Error {
7550
- constructor(statusCode, message) {
7551
- super(message);
7552
- this.statusCode = statusCode;
7553
- this.name = "AppError";
7554
- }
7555
- };
7556
- var NotFoundError = class extends AppError {
7557
- constructor(message = "Not found") {
7558
- super(404, message);
7559
- this.name = "NotFoundError";
7560
- }
7561
- };
7562
- var UnauthorizedError = class extends AppError {
7563
- constructor(message = "Unauthorized") {
7564
- super(401, message);
7565
- this.name = "UnauthorizedError";
7566
- }
7567
- };
7568
- var ForbiddenError = class extends AppError {
7569
- constructor(message = "Forbidden") {
7570
- super(403, message);
7571
- this.name = "ForbiddenError";
7572
- }
7573
- };
7574
- var ConflictError = class extends AppError {
7575
- constructor(message = "Conflict") {
7576
- super(409, message);
7577
- this.name = "ConflictError";
7578
- }
7579
- };
7580
- var BadRequestError = class extends AppError {
7581
- constructor(message = "Bad request") {
7582
- super(400, message);
7583
- this.name = "BadRequestError";
7584
- }
7585
- };
7586
- /**
7587
- * Thrown when an operation targets a client whose organization does not match
7588
- * the caller's authenticated organization. A client is bound to exactly one
7589
- * org for its lifetime; re-registering or operating under a different org's
7590
- * credentials is refused. CLI consumers recognize the `code` field and
7591
- * respond by abandoning the local clientId to register a fresh one.
7592
- */
7593
- var ClientOrgMismatchError = class extends AppError {
7594
- code = "CLIENT_ORG_MISMATCH";
7595
- constructor(message = "Client belongs to a different organization") {
7596
- super(403, message);
7597
- this.name = "ClientOrgMismatchError";
7598
- }
7599
- };
7600
8429
  /** Communication container. All messages between agents flow within a Chat. */
7601
8430
  const chats = pgTable("chats", {
7602
8431
  id: text("id").primaryKey(),
@@ -7747,26 +8576,6 @@ const adapterMessageReferences = pgTable("adapter_message_references", {
7747
8576
  externalChannelId: text("external_channel_id").notNull(),
7748
8577
  createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
7749
8578
  }, (table) => [unique("uq_adapter_message_ref").on(table.messageId, table.platform)]);
7750
- /** Generate a UUID v7 (time-ordered). No external dependency. */
7751
- function uuidv7() {
7752
- const now = BigInt(Date.now());
7753
- const bytes = new Uint8Array(16);
7754
- bytes[0] = Number(now >> 40n & 255n);
7755
- bytes[1] = Number(now >> 32n & 255n);
7756
- bytes[2] = Number(now >> 24n & 255n);
7757
- bytes[3] = Number(now >> 16n & 255n);
7758
- bytes[4] = Number(now >> 8n & 255n);
7759
- bytes[5] = Number(now & 255n);
7760
- const rand = randomBytes(10);
7761
- for (let i = 0; i < 10; i++) {
7762
- const b = rand[i];
7763
- if (b !== void 0) bytes[6 + i] = b;
7764
- }
7765
- bytes[6] = (bytes[6] ?? 0) & 15 | 112;
7766
- bytes[8] = (bytes[8] ?? 0) & 63 | 128;
7767
- const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
7768
- return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
7769
- }
7770
8579
  /** UUID v7 regex pattern for distinguishing UUIDs from name slugs. */
7771
8580
  const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
7772
8581
  /**
@@ -8266,7 +9075,7 @@ async function deleteAdapterConfig(db, id) {
8266
9075
  const [row] = await db.delete(adapterConfigs).where(eq(adapterConfigs.id, id)).returning();
8267
9076
  if (!row) throw new NotFoundError(`Adapter config "${id}" not found`);
8268
9077
  }
8269
- const log$4 = createLogger$1("AdminAdapters");
9078
+ const log$5 = createLogger$1("AdminAdapters");
8270
9079
  function parseId(raw) {
8271
9080
  const id = Number(raw);
8272
9081
  if (!Number.isInteger(id) || id <= 0) throw new BadRequestError(`Invalid adapter ID: "${raw}"`);
@@ -8286,7 +9095,7 @@ async function adminAdapterRoutes(app) {
8286
9095
  const scope = memberScope(request);
8287
9096
  await assertCanManage(app.db, scope, body.agentId);
8288
9097
  const config = await createAdapterConfig(app.db, body, app.config.secrets.encryptionKey);
8289
- app.adapterManager.reload().catch((err) => log$4.error({ err }, "adapter reload failed after create"));
9098
+ app.adapterManager.reload().catch((err) => log$5.error({ err }, "adapter reload failed after create"));
8290
9099
  app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
8291
9100
  return reply.status(201).send({
8292
9101
  ...config,
@@ -8310,7 +9119,7 @@ async function adminAdapterRoutes(app) {
8310
9119
  const existing = await getAdapterConfig(app.db, id);
8311
9120
  await assertCanManage(app.db, scope, existing.agentId);
8312
9121
  const config = await updateAdapterConfig(app.db, id, body, app.config.secrets.encryptionKey);
8313
- app.adapterManager.reload().catch((err) => log$4.error({ err }, "adapter reload failed after update"));
9122
+ app.adapterManager.reload().catch((err) => log$5.error({ err }, "adapter reload failed after update"));
8314
9123
  app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
8315
9124
  return {
8316
9125
  ...config,
@@ -8324,7 +9133,7 @@ async function adminAdapterRoutes(app) {
8324
9133
  const existing = await getAdapterConfig(app.db, id);
8325
9134
  await assertCanManage(app.db, scope, existing.agentId);
8326
9135
  await deleteAdapterConfig(app.db, id);
8327
- app.adapterManager.reload().catch((err) => log$4.error({ err }, "adapter reload failed after delete"));
9136
+ app.adapterManager.reload().catch((err) => log$5.error({ err }, "adapter reload failed after delete"));
8328
9137
  app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
8329
9138
  return reply.status(204).send();
8330
9139
  });
@@ -8411,6 +9220,7 @@ const members = pgTable("members", {
8411
9220
  organizationId: text("organization_id").notNull().references(() => organizations.id),
8412
9221
  agentId: text("agent_id").unique().notNull().references(() => agents.uuid),
8413
9222
  role: text("role").notNull(),
9223
+ status: text("status").notNull().default("active"),
8414
9224
  createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
8415
9225
  }, (table) => [
8416
9226
  unique("uq_members_user_org").on(table.userId, table.organizationId),
@@ -8424,6 +9234,38 @@ const members = pgTable("members", {
8424
9234
  * real account.
8425
9235
  */
8426
9236
  const RESERVED_AGENT_NAME_PREFIX = "__";
9237
+ /**
9238
+ * True iff `clients.metadata.capabilities` is a non-empty object — i.e. the
9239
+ * client has reported at least one runtime probe result. Used to distinguish
9240
+ * "we don't know what's installed yet" (empty / never reported) from
9241
+ * "client explicitly reports this provider is missing".
9242
+ */
9243
+ function clientCapabilitiesReported(metadata) {
9244
+ if (!metadata || typeof metadata !== "object") return false;
9245
+ const caps = metadata.capabilities;
9246
+ if (!caps || typeof caps !== "object") return false;
9247
+ return Object.keys(caps).length > 0;
9248
+ }
9249
+ /**
9250
+ * Inspect a `clients.metadata.capabilities` blob (jsonb) for a specific
9251
+ * runtime provider entry. Capabilities live under the `metadata.capabilities`
9252
+ * subkey (Option C); the column is unstructured at the DB layer, so we
9253
+ * defensively narrow before key access.
9254
+ *
9255
+ * "Supports" requires the entry's SDK to be **available** — `state: "ok"` or
9256
+ * `state: "unauthenticated"`. A `missing` or `error` entry is *reported* but
9257
+ * not usable, so we explicitly reject those rather than treating mere key
9258
+ * presence as support. Auth state is left to the user to fix at runtime
9259
+ * (the re-bind dialog surfaces an `unauthenticated` hint).
9260
+ */
9261
+ function clientSupportsRuntimeProvider(metadata, provider) {
9262
+ if (!metadata || typeof metadata !== "object") return false;
9263
+ const caps = metadata.capabilities;
9264
+ if (!caps || typeof caps !== "object") return false;
9265
+ const entry = caps[provider];
9266
+ if (!entry || typeof entry !== "object") return false;
9267
+ return entry.available === true;
9268
+ }
8427
9269
  /** Default visibility per agent type. */
8428
9270
  function defaultVisibility(type) {
8429
9271
  switch (type) {
@@ -8444,6 +9286,32 @@ function defaultVisibility(type) {
8444
9286
  * - When a non-human agent IS created with a `clientId`, the pinned client
8445
9287
  * must already be owned by the manager's user (Rule R-RUN).
8446
9288
  */
9289
+ /**
9290
+ * Check that a client's reported capabilities show the given runtime provider
9291
+ * as **available** (SDK installed, regardless of auth state).
9292
+ *
9293
+ * Tri-state semantics by `clients.metadata.capabilities` shape:
9294
+ * - empty / absent — client hasn't probed yet (newly registered or pre-P2
9295
+ * install). Treat as "unknown" and allow; the in-band repair path
9296
+ * (RUNTIME_PROVIDER_MISMATCH on bind) catches actual incompatibility.
9297
+ * - reported, entry shows `state: ok | unauthenticated` (i.e. `available:
9298
+ * true`) — allow.
9299
+ * - reported, entry missing OR `state: missing | error` — block unless
9300
+ * `force` is set. We deliberately do NOT treat mere key presence as
9301
+ * support: probeCapabilities() always emits an entry per built-in
9302
+ * provider, including `{ state: "missing" }` for absent SDKs.
9303
+ *
9304
+ * Skipped entirely for human agents (no clientId) and when `force` is set
9305
+ * (e.g. operator overrides for an offline client).
9306
+ */
9307
+ async function ensureClientSupportsRuntimeProvider(db, clientId, runtimeProvider, options = {}) {
9308
+ if (clientId === null) return;
9309
+ if (options.force) return;
9310
+ const [client] = await db.select({ metadata: clients.metadata }).from(clients).where(eq(clients.id, clientId)).limit(1);
9311
+ if (!client) return;
9312
+ if (!clientCapabilitiesReported(client.metadata)) return;
9313
+ if (!clientSupportsRuntimeProvider(client.metadata, runtimeProvider)) throw new BadRequestError(`Client "${clientId}" does not have runtime provider "${runtimeProvider}" available. Install the matching SDK on that machine and re-run capability detection, or retry with \`force: true\` if the client is offline / capabilities are stale.`);
9314
+ }
8447
9315
  async function resolveAgentClient(db, data) {
8448
9316
  if (data.type === "human") {
8449
9317
  if (data.clientId) throw new BadRequestError("Human agents cannot be pinned to a client");
@@ -8476,9 +9344,10 @@ async function resolveFallbackManagerId(db, orgId) {
8476
9344
  if (!row) throw new BadRequestError(`Cannot create agent in organization "${orgId}" — no admin member exists. Create an admin member first (see \`first-tree-hub onboard\`).`);
8477
9345
  return row.id;
8478
9346
  }
8479
- async function createAgent(db, data) {
9347
+ async function createAgent(db, data, options = {}) {
8480
9348
  const uuid = uuidv7();
8481
9349
  const name = data.name ?? null;
9350
+ const runtimeProvider = data.runtimeProvider ?? "claude-code";
8482
9351
  if (name?.startsWith(RESERVED_AGENT_NAME_PREFIX)) throw new BadRequestError(`Agent name "${name}" is reserved — names starting with "${RESERVED_AGENT_NAME_PREFIX}" are Hub-internal`);
8483
9352
  if (name && isReservedAgentName$1(name)) throw new BadRequestError(`Agent name "${name}" is reserved — pick a different one.`);
8484
9353
  const inboxId = `inbox_${uuid}`;
@@ -8504,6 +9373,7 @@ async function createAgent(db, data) {
8504
9373
  managerId,
8505
9374
  type: data.type
8506
9375
  });
9376
+ await ensureClientSupportsRuntimeProvider(db, clientId, runtimeProvider, { force: options.force });
8507
9377
  const [org] = await db.select({ maxAgents: organizations.maxAgents }).from(organizations).where(eq(organizations.id, orgId)).limit(1);
8508
9378
  if (org && org.maxAgents > 0) {
8509
9379
  if (((await db.select({ value: count() }).from(agents).where(and(eq(agents.organizationId, orgId), ne(agents.status, AGENT_STATUSES.DELETED))))[0]?.value ?? 0) >= org.maxAgents) throw new ForbiddenError(`Organization "${orgId}" has reached its agent limit (${org.maxAgents}). Upgrade your plan or delete unused agents.`);
@@ -8522,13 +9392,14 @@ async function createAgent(db, data) {
8522
9392
  visibility: data.visibility ?? defaultVisibility(data.type),
8523
9393
  metadata: data.metadata ?? {},
8524
9394
  managerId,
8525
- clientId
9395
+ clientId,
9396
+ runtimeProvider
8526
9397
  }).returning();
8527
9398
  if (!agent) throw new Error("Unexpected: INSERT RETURNING produced no row");
8528
9399
  await db.insert(agentConfigs).values({
8529
9400
  agentId: agent.uuid,
8530
9401
  version: 1,
8531
- payload: DEFAULT_AGENT_RUNTIME_CONFIG_PAYLOAD,
9402
+ payload: defaultRuntimeConfigPayload(runtimeProvider),
8532
9403
  updatedBy: "system"
8533
9404
  }).onConflictDoNothing();
8534
9405
  return agent;
@@ -8582,6 +9453,7 @@ async function listAgentsForAdmin(db, scope, limit, cursor) {
8582
9453
  metadata: agents.metadata,
8583
9454
  managerId: agents.managerId,
8584
9455
  clientId: agents.clientId,
9456
+ runtimeProvider: agents.runtimeProvider,
8585
9457
  createdAt: agents.createdAt,
8586
9458
  updatedAt: agents.updatedAt,
8587
9459
  presenceStatus: agentPresence.status,
@@ -8619,6 +9491,7 @@ async function listAgentsForMember(db, scope, limit, cursor, type) {
8619
9491
  metadata: agents.metadata,
8620
9492
  managerId: agents.managerId,
8621
9493
  clientId: agents.clientId,
9494
+ runtimeProvider: agents.runtimeProvider,
8622
9495
  createdAt: agents.createdAt,
8623
9496
  updatedAt: agents.updatedAt,
8624
9497
  presenceStatus: agentPresence.status,
@@ -8638,7 +9511,7 @@ async function updateAgent(db, uuid, data) {
8638
9511
  const agent = await getAgent(db, uuid);
8639
9512
  if (data.clientId !== void 0) {
8640
9513
  if (data.clientId === null) throw new BadRequestError("clientId cannot be cleared — once bound, an agent stays bound to its client");
8641
- if (agent.clientId !== null && agent.clientId !== data.clientId) throw new BadRequestError("clientId is immutable once setdelete and re-create the agent on the target client to move it");
9514
+ if (agent.clientId !== null && agent.clientId !== data.clientId) throw new BadRequestError("clientId is immutable through this entry cross-client moves go through rebindAgent (PATCH /admin/agents/:agentId/rebind), which runs owner / org / capability checks atomically.");
8642
9515
  }
8643
9516
  const updates = { updatedAt: /* @__PURE__ */ new Date() };
8644
9517
  if (data.type !== void 0) updates.type = data.type;
@@ -8669,6 +9542,39 @@ async function updateAgent(db, uuid, data) {
8669
9542
  return updated;
8670
9543
  }
8671
9544
  /**
9545
+ * Atomically re-bind an agent to a new client and/or runtime provider.
9546
+ *
9547
+ * Validations: agent must exist and not be human; new client must belong to
9548
+ * the same owner (manager.userId) and same organization; client must report
9549
+ * the requested runtime provider in its capabilities (skipped under `force`).
9550
+ *
9551
+ * Intended caller: PATCH /admin/agents/:agentId/rebind. The Web "Re-bind"
9552
+ * dialog routes both same-client runtime-only switches and cross-client
9553
+ * moves through this single entry.
9554
+ *
9555
+ * NOTE: active sessions on the previous client are not auto-suspended in P1.
9556
+ * P3 will wire in cross-service coordination (inbox + presence + session)
9557
+ * so the destination client can resume cleanly.
9558
+ */
9559
+ async function rebindAgent(db, uuid, data) {
9560
+ const agent = await getAgent(db, uuid);
9561
+ if (agent.type === "human") throw new BadRequestError("Human agents have no runtime — they cannot be re-bound to a client.");
9562
+ const newClientId = await resolveAgentClient(db, {
9563
+ clientId: data.clientId,
9564
+ managerId: agent.managerId,
9565
+ type: agent.type
9566
+ });
9567
+ if (newClientId === null) throw new BadRequestError("Rebind requires a non-null clientId.");
9568
+ await ensureClientSupportsRuntimeProvider(db, newClientId, data.runtimeProvider, { force: data.force });
9569
+ const [updated] = await db.update(agents).set({
9570
+ clientId: newClientId,
9571
+ runtimeProvider: data.runtimeProvider,
9572
+ updatedAt: /* @__PURE__ */ new Date()
9573
+ }).where(eq(agents.uuid, uuid)).returning();
9574
+ if (!updated) throw new Error("Unexpected: UPDATE RETURNING produced no row");
9575
+ return updated;
9576
+ }
9577
+ /**
8672
9578
  * Reactivate a suspended agent.
8673
9579
  */
8674
9580
  async function reactivateAgent(db, uuid) {
@@ -9188,7 +10094,7 @@ async function registerClient(db, data) {
9188
10094
  userId: clients.userId,
9189
10095
  organizationId: clients.organizationId
9190
10096
  }).from(clients).where(eq(clients.id, data.clientId)).limit(1);
9191
- if (existing && existing.organizationId !== data.organizationId) throw new ClientOrgMismatchError(`Client "${data.clientId}" is bound to a different organization. Re-register as a new client under the current org.`);
10097
+ 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.`);
9192
10098
  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.`);
9193
10099
  await db.insert(clients).values({
9194
10100
  id: data.clientId,
@@ -9248,10 +10154,45 @@ async function listActiveAgentsPinnedToClient(db, clientId) {
9248
10154
  uuid: agents.uuid,
9249
10155
  name: agents.name,
9250
10156
  displayName: agents.displayName,
9251
- type: agents.type
10157
+ type: agents.type,
10158
+ runtimeProvider: agents.runtimeProvider
9252
10159
  }).from(agents).where(and(eq(agents.clientId, clientId), ne(agents.status, "deleted")));
9253
10160
  }
9254
10161
  /**
10162
+ * Member-scoped: every active agent pinned to a client owned by this user
10163
+ * within the given organization. Used by client startup to reconcile its
10164
+ * local YAML against the authoritative `agents.runtime_provider`.
10165
+ */
10166
+ async function listMyPinnedAgents(db, scope) {
10167
+ return (await db.select({
10168
+ agentId: agents.uuid,
10169
+ clientId: agents.clientId,
10170
+ runtimeProvider: agents.runtimeProvider
10171
+ }).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) => ({
10172
+ agentId: r.agentId,
10173
+ clientId: r.clientId,
10174
+ runtimeProvider: r.runtimeProvider
10175
+ }));
10176
+ }
10177
+ /**
10178
+ * Replace this client's capabilities snapshot. Capabilities live under
10179
+ * `clients.metadata.capabilities` (Option C — no dedicated column); other
10180
+ * `metadata` subkeys are preserved on merge.
10181
+ *
10182
+ * Caller is expected to have already passed `assertClientOwner`.
10183
+ */
10184
+ async function updateClientCapabilities(db, clientId, capabilities) {
10185
+ const parsed = clientCapabilitiesSchema$1.safeParse(capabilities);
10186
+ if (!parsed.success) throw new BadRequestError(`Invalid capabilities payload: ${parsed.error.message}`);
10187
+ const [client] = await db.select({ metadata: clients.metadata }).from(clients).where(eq(clients.id, clientId)).limit(1);
10188
+ if (!client) throw new NotFoundError(`Client "${clientId}" not found`);
10189
+ const merged = {
10190
+ ...client.metadata ?? {},
10191
+ capabilities: parsed.data
10192
+ };
10193
+ await db.update(clients).set({ metadata: merged }).where(eq(clients.id, clientId));
10194
+ }
10195
+ /**
9255
10196
  * Scope-aware client listing.
9256
10197
  *
9257
10198
  * - member: rows where `user_id = scope.userId` AND `organization_id = scope.organizationId`
@@ -9464,51 +10405,178 @@ const inboxEntries = pgTable("inbox_entries", {
9464
10405
  index("idx_inbox_pending_notify").on(table.inboxId, table.createdAt).where(sql`status = 'pending' AND notify = true`),
9465
10406
  index("idx_inbox_chat_silent").on(table.inboxId, table.chatId, table.notify, table.status)
9466
10407
  ]);
9467
- async function sendMessage(db, chatId, senderId, data, options = {}) {
9468
- return withSpan("inbox.enqueue", messageAttrs({
9469
- chatId,
9470
- senderAgentId: senderId,
9471
- source: data.source ?? void 0
9472
- }), () => sendMessageInner(db, chatId, senderId, data, options));
9473
- }
9474
- async function sendMessageInner(db, chatId, senderId, data, options) {
9475
- return db.transaction(async (tx) => {
9476
- const [participants, [chatRow]] = await Promise.all([tx.select({
9477
- agentId: chatParticipants.agentId,
9478
- inboxId: agents.inboxId,
9479
- mode: chatParticipants.mode,
9480
- name: agents.name
9481
- }).from(chatParticipants).innerJoin(agents, eq(chatParticipants.agentId, agents.uuid)).where(eq(chatParticipants.chatId, chatId)), tx.select({ type: chats.type }).from(chats).where(eq(chats.id, chatId)).limit(1)]);
9482
- const chatType = chatRow?.type ?? null;
9483
- if (data.replyToInbox !== void 0 && data.replyToInbox !== null) {
9484
- const [senderRow] = await tx.select({ inboxId: agents.inboxId }).from(agents).where(eq(agents.uuid, senderId)).limit(1);
9485
- if (!senderRow || senderRow.inboxId !== data.replyToInbox) throw new BadRequestError("replyToInbox must reference the sender's own inbox");
9486
- }
9487
- const incomingMeta = data.metadata ?? {};
9488
- const explicitMentionsRaw = incomingMeta.mentions;
9489
- const explicitMentions = Array.isArray(explicitMentionsRaw) ? explicitMentionsRaw.filter((m) => typeof m === "string") : [];
9490
- const contentText = typeof data.content === "string" ? data.content : "";
9491
- const resolved = contentText ? extractMentions(contentText, participants) : [];
9492
- const mergedMentions = [...new Set([...explicitMentions, ...resolved])];
9493
- const metadataToStore = mergedMentions.length > 0 ? {
9494
- ...incomingMeta,
9495
- mentions: mergedMentions
9496
- } : incomingMeta;
9497
- if (options.enforceGroupMention && chatType === "group") {
9498
- if (mergedMentions.filter((id) => id !== senderId).length === 0) throw new BadRequestError("Sending to a group chat requires an explicit @mention. Use `agent send <name>` to message a single agent, or @<name> in the content to address one or more group members.");
9499
- }
9500
- let outboundContent = data.content;
9501
- if (options.normalizeMentionsInContent && typeof outboundContent === "string") {
9502
- const present = new Set(scanMentionTokens(outboundContent));
9503
- const missingNames = [];
9504
- for (const id of mergedMentions) {
9505
- if (id === senderId) continue;
9506
- const p = participants.find((q) => q.agentId === id);
9507
- if (!p?.name) continue;
9508
- if (present.has(p.name.toLowerCase())) continue;
9509
- missingNames.push(p.name);
9510
- }
9511
- if (missingNames.length > 0) {
10408
+ /** Per-session state snapshot. One row per (agent, chat) pair, upserted on each session:state message. */
10409
+ const agentChatSessions = pgTable("agent_chat_sessions", {
10410
+ agentId: text("agent_id").notNull().references(() => agents.uuid, { onDelete: "cascade" }),
10411
+ chatId: text("chat_id").notNull().references(() => chats.id, { onDelete: "cascade" }),
10412
+ state: text("state").notNull(),
10413
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
10414
+ }, (table) => [primaryKey({ columns: [table.agentId, table.chatId] })]);
10415
+ /**
10416
+ * Upsert session state + refresh presence aggregates + NOTIFY.
10417
+ *
10418
+ * `agent_chat_sessions.(agent_id, chat_id)` is a single-row "current session
10419
+ * state" cache, not a session history log. A new runtime session starting on
10420
+ * the same (agent, chat) pair MUST overwrite whatever ended before — including
10421
+ * an `evicted` row left by a previous terminate. The previous "revival
10422
+ * defense" conflated two concerns: "this runtime session ended" (which is
10423
+ * what `evicted` actually means) and "this chat is permanently archived for
10424
+ * this agent" (a chat-level decision that should live on `chats`, not here).
10425
+ * See proposals/hub-agent-messaging-reply-and-mentions §M2-session-lifecycle.
10426
+ *
10427
+ * Presence row contract: this function tolerates a missing `agent_presence`
10428
+ * row by using `INSERT ... ON CONFLICT DO UPDATE`. The predictive-write path
10429
+ * (sendMessage on first message) may target an agent whose client has never
10430
+ * bound, so a prior `update agent_presence ... where agentId` would silently
10431
+ * drop the activeSessions/totalSessions refresh. See PR #198 review §2.
10432
+ */
10433
+ async function upsertSessionState(db, agentId, chatId, state, organizationId, notifier, options) {
10434
+ const now = /* @__PURE__ */ new Date();
10435
+ let wrote = false;
10436
+ await db.transaction(async (tx) => {
10437
+ await tx.insert(agentChatSessions).values({
10438
+ agentId,
10439
+ chatId,
10440
+ state,
10441
+ updatedAt: now
10442
+ }).onConflictDoUpdate({
10443
+ target: [agentChatSessions.agentId, agentChatSessions.chatId],
10444
+ set: {
10445
+ state,
10446
+ updatedAt: now
10447
+ },
10448
+ setWhere: ne(agentChatSessions.state, state)
10449
+ });
10450
+ const [counts] = await tx.select({
10451
+ active: sql`count(*) FILTER (WHERE ${agentChatSessions.state} = 'active')::int`,
10452
+ total: sql`count(*) FILTER (WHERE ${agentChatSessions.state} != 'evicted')::int`
10453
+ }).from(agentChatSessions).where(eq(agentChatSessions.agentId, agentId));
10454
+ const activeSessions = counts?.active ?? 0;
10455
+ const totalSessions = counts?.total ?? 0;
10456
+ const presenceSet = options?.touchPresenceLastSeen ?? true ? {
10457
+ activeSessions,
10458
+ totalSessions,
10459
+ lastSeenAt: now
10460
+ } : {
10461
+ activeSessions,
10462
+ totalSessions
10463
+ };
10464
+ await tx.insert(agentPresence).values({
10465
+ agentId,
10466
+ activeSessions,
10467
+ totalSessions
10468
+ }).onConflictDoUpdate({
10469
+ target: [agentPresence.agentId],
10470
+ set: presenceSet
10471
+ });
10472
+ wrote = true;
10473
+ });
10474
+ if (wrote && notifier) notifier.notifySessionStateChange(agentId, chatId, state, organizationId).catch(() => {});
10475
+ }
10476
+ async function resetActivity(db, agentId) {
10477
+ const now = /* @__PURE__ */ new Date();
10478
+ await db.update(agentPresence).set({
10479
+ runtimeState: "idle",
10480
+ runtimeUpdatedAt: now
10481
+ }).where(eq(agentPresence.agentId, agentId));
10482
+ }
10483
+ async function getActivityOverview(db) {
10484
+ const [agentCounts] = await db.select({
10485
+ total: sql`count(*)::int`,
10486
+ running: sql`count(*) FILTER (WHERE ${agentPresence.runtimeState} IS NOT NULL)::int`,
10487
+ idle: sql`count(*) FILTER (WHERE ${agentPresence.runtimeState} = 'idle')::int`,
10488
+ working: sql`count(*) FILTER (WHERE ${agentPresence.runtimeState} = 'working')::int`,
10489
+ blocked: sql`count(*) FILTER (WHERE ${agentPresence.runtimeState} = 'blocked')::int`,
10490
+ error: sql`count(*) FILTER (WHERE ${agentPresence.runtimeState} = 'error')::int`
10491
+ }).from(agentPresence);
10492
+ const [clientCounts] = await db.select({ count: sql`count(*)::int` }).from(clients).where(eq(clients.status, "connected"));
10493
+ return {
10494
+ total: agentCounts?.total ?? 0,
10495
+ running: agentCounts?.running ?? 0,
10496
+ byState: {
10497
+ idle: agentCounts?.idle ?? 0,
10498
+ working: agentCounts?.working ?? 0,
10499
+ blocked: agentCounts?.blocked ?? 0,
10500
+ error: agentCounts?.error ?? 0
10501
+ },
10502
+ clients: clientCounts?.count ?? 0
10503
+ };
10504
+ }
10505
+ /**
10506
+ * List agents with active runtime state.
10507
+ * When scope is provided, filters to agents visible to the member.
10508
+ */
10509
+ async function listAgentsWithRuntime(db, scope) {
10510
+ if (!scope) return db.select().from(agentPresence).where(isNotNull(agentPresence.runtimeState));
10511
+ return db.select({
10512
+ agentId: agentPresence.agentId,
10513
+ status: agentPresence.status,
10514
+ instanceId: agentPresence.instanceId,
10515
+ connectedAt: agentPresence.connectedAt,
10516
+ lastSeenAt: agentPresence.lastSeenAt,
10517
+ clientId: agentPresence.clientId,
10518
+ runtimeType: agentPresence.runtimeType,
10519
+ runtimeVersion: agentPresence.runtimeVersion,
10520
+ runtimeState: agentPresence.runtimeState,
10521
+ activeSessions: agentPresence.activeSessions,
10522
+ totalSessions: agentPresence.totalSessions,
10523
+ runtimeUpdatedAt: agentPresence.runtimeUpdatedAt,
10524
+ type: agents.type
10525
+ }).from(agentPresence).innerJoin(agents, eq(agentPresence.agentId, agents.uuid)).where(and(isNotNull(agentPresence.runtimeState), agentVisibilityCondition(scope)));
10526
+ }
10527
+ const log$4 = createLogger$1("message");
10528
+ async function sendMessage(db, chatId, senderId, data, options = {}) {
10529
+ return withSpan("inbox.enqueue", messageAttrs({
10530
+ chatId,
10531
+ senderAgentId: senderId,
10532
+ source: data.source ?? void 0
10533
+ }), () => sendMessageInner(db, chatId, senderId, data, options));
10534
+ }
10535
+ async function sendMessageInner(db, chatId, senderId, data, options) {
10536
+ const txResult = await db.transaction(async (tx) => {
10537
+ const [participants, [chatRow], [senderRow]] = await Promise.all([
10538
+ tx.select({
10539
+ agentId: chatParticipants.agentId,
10540
+ inboxId: agents.inboxId,
10541
+ mode: chatParticipants.mode,
10542
+ name: agents.name
10543
+ }).from(chatParticipants).innerJoin(agents, eq(chatParticipants.agentId, agents.uuid)).where(eq(chatParticipants.chatId, chatId)),
10544
+ tx.select({ type: chats.type }).from(chats).where(eq(chats.id, chatId)).limit(1),
10545
+ tx.select({
10546
+ inboxId: agents.inboxId,
10547
+ organizationId: agents.organizationId
10548
+ }).from(agents).where(eq(agents.uuid, senderId)).limit(1)
10549
+ ]);
10550
+ const chatType = chatRow?.type ?? null;
10551
+ if (!senderRow) throw new NotFoundError(`Sender agent "${senderId}" not found`);
10552
+ if (data.replyToInbox !== void 0 && data.replyToInbox !== null) {
10553
+ if (senderRow.inboxId !== data.replyToInbox) throw new BadRequestError("replyToInbox must reference the sender's own inbox");
10554
+ }
10555
+ const incomingMeta = data.metadata ?? {};
10556
+ const explicitMentionsRaw = incomingMeta.mentions;
10557
+ const explicitMentions = Array.isArray(explicitMentionsRaw) ? explicitMentionsRaw.filter((m) => typeof m === "string") : [];
10558
+ const contentText = typeof data.content === "string" ? data.content : "";
10559
+ const resolved = contentText ? extractMentions(contentText, participants) : [];
10560
+ const mergedMentions = [...new Set([...explicitMentions, ...resolved])];
10561
+ const metadataToStore = mergedMentions.length > 0 ? {
10562
+ ...incomingMeta,
10563
+ mentions: mergedMentions
10564
+ } : incomingMeta;
10565
+ if (options.enforceGroupMention && chatType === "group") {
10566
+ if (mergedMentions.filter((id) => id !== senderId).length === 0) throw new BadRequestError("Sending to a group chat requires an explicit @mention. Use `agent send <name>` to message a single agent, or @<name> in the content to address one or more group members.");
10567
+ }
10568
+ let outboundContent = data.content;
10569
+ if (options.normalizeMentionsInContent && typeof outboundContent === "string") {
10570
+ const present = new Set(scanMentionTokens(outboundContent));
10571
+ const missingNames = [];
10572
+ for (const id of mergedMentions) {
10573
+ if (id === senderId) continue;
10574
+ const p = participants.find((q) => q.agentId === id);
10575
+ if (!p?.name) continue;
10576
+ if (present.has(p.name.toLowerCase())) continue;
10577
+ missingNames.push(p.name);
10578
+ }
10579
+ if (missingNames.length > 0) {
9512
10580
  const prefix = missingNames.map((n) => `@${n}`).join(" ");
9513
10581
  outboundContent = outboundContent.length > 0 ? `${prefix} ${outboundContent}` : prefix;
9514
10582
  }
@@ -9527,14 +10595,20 @@ async function sendMessageInner(db, chatId, senderId, data, options) {
9527
10595
  source: data.source ?? null
9528
10596
  }).returning();
9529
10597
  const mentionSet = new Set(mergedMentions);
9530
- const entries = participants.filter((p) => p.agentId !== senderId).map((p) => ({
10598
+ const fanout = participants.filter((p) => p.agentId !== senderId).map((p) => ({
10599
+ agentId: p.agentId,
9531
10600
  inboxId: p.inboxId,
9532
- messageId,
9533
- chatId,
9534
10601
  notify: p.mode !== "mention_only" || mentionSet.has(p.agentId)
9535
10602
  }));
9536
- if (entries.length > 0) await tx.insert(inboxEntries).values(entries);
9537
- const recipients = entries.filter((e) => e.notify).map((e) => e.inboxId);
10603
+ if (fanout.length > 0) await tx.insert(inboxEntries).values(fanout.map((f) => ({
10604
+ inboxId: f.inboxId,
10605
+ messageId,
10606
+ chatId,
10607
+ notify: f.notify
10608
+ })));
10609
+ const notified = fanout.filter((f) => f.notify);
10610
+ const recipients = notified.map((f) => f.inboxId);
10611
+ const recipientAgentIds = notified.map((f) => f.agentId);
9538
10612
  if (data.inReplyTo) {
9539
10613
  const [original] = await tx.select({
9540
10614
  replyToInbox: messages.replyToInbox,
@@ -9553,9 +10627,24 @@ async function sendMessageInner(db, chatId, senderId, data, options) {
9553
10627
  if (!msg) throw new Error("Unexpected: INSERT RETURNING produced no row");
9554
10628
  return {
9555
10629
  message: msg,
9556
- recipients
10630
+ recipients,
10631
+ recipientAgentIds,
10632
+ organizationId: senderRow.organizationId
9557
10633
  };
9558
10634
  });
10635
+ const settled = await Promise.allSettled(txResult.recipientAgentIds.map((agentId) => upsertSessionState(db, agentId, chatId, "active", txResult.organizationId, void 0, { touchPresenceLastSeen: false })));
10636
+ for (let i = 0; i < settled.length; i++) {
10637
+ const r = settled[i];
10638
+ if (r?.status === "rejected") log$4.error({
10639
+ err: r.reason,
10640
+ chatId,
10641
+ agentId: txResult.recipientAgentIds[i]
10642
+ }, "predictive session activation failed");
10643
+ }
10644
+ return {
10645
+ message: txResult.message,
10646
+ recipients: txResult.recipients
10647
+ };
9559
10648
  }
9560
10649
  async function sendToAgent(db, senderUuid, targetName, data) {
9561
10650
  const [sender] = await db.select({
@@ -9782,7 +10871,8 @@ async function adminAgentRoutes(app) {
9782
10871
  agentId: agent.uuid,
9783
10872
  name: agent.name,
9784
10873
  displayName: agent.displayName,
9785
- agentType: agent.type
10874
+ agentType: agent.type,
10875
+ runtimeProvider: agent.runtimeProvider
9786
10876
  });
9787
10877
  if (!parsed.success) {
9788
10878
  app.log.warn({
@@ -9885,6 +10975,23 @@ async function adminAgentRoutes(app) {
9885
10975
  updatedAt: agent.updatedAt.toISOString()
9886
10976
  };
9887
10977
  });
10978
+ /**
10979
+ * Rebind an agent to a new client and/or runtime provider. Re-runs owner /
10980
+ * org / capability checks atomically. Capability mismatch can be overridden
10981
+ * with `force: true` (e.g. client offline, capabilities stale).
10982
+ */
10983
+ app.patch("/:uuid/rebind", async (request) => {
10984
+ const scope = memberScope(request);
10985
+ await assertCanManage(app.db, scope, request.params.uuid);
10986
+ const body = rebindAgentSchema.parse(request.body);
10987
+ const agent = await rebindAgent(app.db, request.params.uuid, body);
10988
+ notifyClientAgentPinned(agent);
10989
+ return {
10990
+ ...agent,
10991
+ createdAt: agent.createdAt.toISOString(),
10992
+ updatedAt: agent.updatedAt.toISOString()
10993
+ };
10994
+ });
9888
10995
  app.get("/:uuid", async (request) => {
9889
10996
  const scope = memberScope(request);
9890
10997
  await assertAgentVisible(app.db, scope, request.params.uuid);
@@ -10059,9 +11166,10 @@ function memberAuthHook(db, jwtSecret) {
10059
11166
  id: members.id,
10060
11167
  organizationId: members.organizationId,
10061
11168
  role: members.role,
10062
- agentId: members.agentId
11169
+ agentId: members.agentId,
11170
+ status: members.status
10063
11171
  }).from(members).where(eq(members.id, payload.memberId)).limit(1);
10064
- if (!member) throw new UnauthorizedError("Membership not found");
11172
+ if (!member || member.status !== "active") throw new UnauthorizedError("Membership not found");
10065
11173
  request.member = {
10066
11174
  userId: user.id,
10067
11175
  memberId: member.id,
@@ -10338,107 +11446,6 @@ async function adminChatRoutes(app) {
10338
11446
  });
10339
11447
  });
10340
11448
  }
10341
- /** Per-session state snapshot. One row per (agent, chat) pair, upserted on each session:state message. */
10342
- const agentChatSessions = pgTable("agent_chat_sessions", {
10343
- agentId: text("agent_id").notNull().references(() => agents.uuid, { onDelete: "cascade" }),
10344
- chatId: text("chat_id").notNull().references(() => chats.id, { onDelete: "cascade" }),
10345
- state: text("state").notNull(),
10346
- updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
10347
- }, (table) => [primaryKey({ columns: [table.agentId, table.chatId] })]);
10348
- /**
10349
- * Upsert session state + refresh presence aggregates + NOTIFY.
10350
- *
10351
- * `agent_chat_sessions.(agent_id, chat_id)` is a single-row "current session
10352
- * state" cache, not a session history log. A new runtime session starting on
10353
- * the same (agent, chat) pair MUST overwrite whatever ended before — including
10354
- * an `evicted` row left by a previous terminate. The previous "revival
10355
- * defense" conflated two concerns: "this runtime session ended" (which is
10356
- * what `evicted` actually means) and "this chat is permanently archived for
10357
- * this agent" (a chat-level decision that should live on `chats`, not here).
10358
- * See proposals/hub-agent-messaging-reply-and-mentions §M2-session-lifecycle.
10359
- */
10360
- async function upsertSessionState(db, agentId, chatId, state, organizationId, notifier) {
10361
- const now = /* @__PURE__ */ new Date();
10362
- let wrote = false;
10363
- await db.transaction(async (tx) => {
10364
- await tx.insert(agentChatSessions).values({
10365
- agentId,
10366
- chatId,
10367
- state,
10368
- updatedAt: now
10369
- }).onConflictDoUpdate({
10370
- target: [agentChatSessions.agentId, agentChatSessions.chatId],
10371
- set: {
10372
- state,
10373
- updatedAt: now
10374
- }
10375
- });
10376
- const [counts] = await tx.select({
10377
- active: sql`count(*) FILTER (WHERE ${agentChatSessions.state} = 'active')::int`,
10378
- total: sql`count(*) FILTER (WHERE ${agentChatSessions.state} != 'evicted')::int`
10379
- }).from(agentChatSessions).where(eq(agentChatSessions.agentId, agentId));
10380
- const activeSessions = counts?.active ?? 0;
10381
- const totalSessions = counts?.total ?? 0;
10382
- await tx.update(agentPresence).set({
10383
- activeSessions,
10384
- totalSessions,
10385
- lastSeenAt: now
10386
- }).where(eq(agentPresence.agentId, agentId));
10387
- wrote = true;
10388
- });
10389
- if (wrote && notifier) notifier.notifySessionStateChange(agentId, chatId, state, organizationId).catch(() => {});
10390
- }
10391
- async function resetActivity(db, agentId) {
10392
- const now = /* @__PURE__ */ new Date();
10393
- await db.update(agentPresence).set({
10394
- runtimeState: "idle",
10395
- runtimeUpdatedAt: now
10396
- }).where(eq(agentPresence.agentId, agentId));
10397
- }
10398
- async function getActivityOverview(db) {
10399
- const [agentCounts] = await db.select({
10400
- total: sql`count(*)::int`,
10401
- running: sql`count(*) FILTER (WHERE ${agentPresence.runtimeState} IS NOT NULL)::int`,
10402
- idle: sql`count(*) FILTER (WHERE ${agentPresence.runtimeState} = 'idle')::int`,
10403
- working: sql`count(*) FILTER (WHERE ${agentPresence.runtimeState} = 'working')::int`,
10404
- blocked: sql`count(*) FILTER (WHERE ${agentPresence.runtimeState} = 'blocked')::int`,
10405
- error: sql`count(*) FILTER (WHERE ${agentPresence.runtimeState} = 'error')::int`
10406
- }).from(agentPresence);
10407
- const [clientCounts] = await db.select({ count: sql`count(*)::int` }).from(clients).where(eq(clients.status, "connected"));
10408
- return {
10409
- total: agentCounts?.total ?? 0,
10410
- running: agentCounts?.running ?? 0,
10411
- byState: {
10412
- idle: agentCounts?.idle ?? 0,
10413
- working: agentCounts?.working ?? 0,
10414
- blocked: agentCounts?.blocked ?? 0,
10415
- error: agentCounts?.error ?? 0
10416
- },
10417
- clients: clientCounts?.count ?? 0
10418
- };
10419
- }
10420
- /**
10421
- * List agents with active runtime state.
10422
- * When scope is provided, filters to agents visible to the member.
10423
- */
10424
- async function listAgentsWithRuntime(db, scope) {
10425
- if (!scope) return db.select().from(agentPresence).where(isNotNull(agentPresence.runtimeState));
10426
- return db.select({
10427
- agentId: agentPresence.agentId,
10428
- status: agentPresence.status,
10429
- instanceId: agentPresence.instanceId,
10430
- connectedAt: agentPresence.connectedAt,
10431
- lastSeenAt: agentPresence.lastSeenAt,
10432
- clientId: agentPresence.clientId,
10433
- runtimeType: agentPresence.runtimeType,
10434
- runtimeVersion: agentPresence.runtimeVersion,
10435
- runtimeState: agentPresence.runtimeState,
10436
- activeSessions: agentPresence.activeSessions,
10437
- totalSessions: agentPresence.totalSessions,
10438
- runtimeUpdatedAt: agentPresence.runtimeUpdatedAt,
10439
- type: agents.type
10440
- }).from(agentPresence).innerJoin(agents, eq(agentPresence.agentId, agents.uuid)).where(and(isNotNull(agentPresence.runtimeState), agentVisibilityCondition(scope)));
10441
- }
10442
11449
  /** Serialize a Date to ISO string, or null. */
10443
11450
  function serializeDate(d) {
10444
11451
  return d ? d.toISOString() : null;
@@ -10462,11 +11469,27 @@ async function adminClientRoutes(app) {
10462
11469
  lastSeenAt: c.lastSeenAt.toISOString()
10463
11470
  }));
10464
11471
  });
11472
+ app.get("/me/agents", async (request) => {
11473
+ const scope = memberScope(request);
11474
+ return await listMyPinnedAgents(app.db, {
11475
+ userId: scope.userId,
11476
+ organizationId: scope.organizationId
11477
+ });
11478
+ });
11479
+ app.patch("/:clientId/capabilities", async (request, reply) => {
11480
+ const scope = memberScope(request);
11481
+ await assertClientOwner(app.db, request.params.clientId, scope);
11482
+ const body = updateClientCapabilitiesSchema.parse(request.body);
11483
+ await updateClientCapabilities(app.db, request.params.clientId, body.capabilities);
11484
+ return reply.status(204).send();
11485
+ });
10465
11486
  app.get("/:clientId", async (request) => {
10466
11487
  const scope = memberScope(request);
10467
11488
  await assertClientOwner(app.db, request.params.clientId, scope);
10468
11489
  const client = await getClient(app.db, request.params.clientId);
10469
11490
  if (!client) throw new Error("unreachable: client missing after owner check");
11491
+ const metadata = client.metadata ?? {};
11492
+ const capabilities = metadata.capabilities && typeof metadata.capabilities === "object" ? metadata.capabilities : {};
10470
11493
  return {
10471
11494
  id: client.id,
10472
11495
  userId: client.userId,
@@ -10475,7 +11498,8 @@ async function adminClientRoutes(app) {
10475
11498
  hostname: client.hostname,
10476
11499
  os: client.os,
10477
11500
  connectedAt: serializeDate(client.connectedAt),
10478
- lastSeenAt: client.lastSeenAt.toISOString()
11501
+ lastSeenAt: client.lastSeenAt.toISOString(),
11502
+ capabilities
10479
11503
  };
10480
11504
  });
10481
11505
  app.post("/:clientId/disconnect", async (request) => {
@@ -12750,7 +13774,7 @@ function clientWsRoutes(notifier, instanceId) {
12750
13774
  });
12751
13775
  } catch (err) {
12752
13776
  const message = err instanceof Error ? err.message : "client register failed";
12753
- const code = err instanceof ClientOrgMismatchError ? err.code : void 0;
13777
+ const code = err instanceof ClientOrgMismatchError$1 ? err.code : void 0;
12754
13778
  socket.send(JSON.stringify({
12755
13779
  type: "client:register:rejected",
12756
13780
  message,
@@ -12774,7 +13798,8 @@ function clientWsRoutes(notifier, instanceId) {
12774
13798
  agentId: agent.uuid,
12775
13799
  name: agent.name,
12776
13800
  displayName: agent.displayName,
12777
- agentType: agent.type
13801
+ agentType: agent.type,
13802
+ runtimeProvider: agent.runtimeProvider
12778
13803
  });
12779
13804
  if (!parsed.success) {
12780
13805
  app.log.warn({
@@ -12810,6 +13835,7 @@ function clientWsRoutes(notifier, instanceId) {
12810
13835
  inboxId: agents.inboxId,
12811
13836
  status: agents.status,
12812
13837
  clientId: agents.clientId,
13838
+ runtimeProvider: agents.runtimeProvider,
12813
13839
  clientUserId: clients.userId,
12814
13840
  managerUserId: members.userId
12815
13841
  }).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);
@@ -12844,6 +13870,10 @@ function clientWsRoutes(notifier, instanceId) {
12844
13870
  sendRejected(socket, ref, AGENT_BIND_REJECT_REASONS.NOT_OWNED);
12845
13871
  return;
12846
13872
  }
13873
+ if (bindRequest.runtimeType !== agent.runtimeProvider) {
13874
+ sendRejected(socket, ref, AGENT_BIND_REJECT_REASONS.RUNTIME_PROVIDER_MISMATCH);
13875
+ return;
13876
+ }
12847
13877
  await bindAgent(app.db, agent.id, {
12848
13878
  clientId,
12849
13879
  instanceId,
@@ -13025,122 +14055,722 @@ const CONNECT_JTI_TTL_MS = 6e5;
13025
14055
  async function signToken(secret, payload, expiry) {
13026
14056
  return new SignJWT({ ...payload }).setProtectedHeader({ alg: "HS256" }).setIssuedAt().setExpirationTime(expiry).sign(secret);
13027
14057
  }
14058
+ /**
14059
+ * Sign an `(access, refresh)` pair for the given member. Used by both the
14060
+ * legacy username/password login path and the SaaS GitHub OAuth callback,
14061
+ * so the issuance shape stays in one place.
14062
+ */
14063
+ async function signTokensForMember(jwtSecretKey, member) {
14064
+ const secret = new TextEncoder().encode(jwtSecretKey);
14065
+ const tokenBase = {
14066
+ sub: member.userId,
14067
+ memberId: member.memberId,
14068
+ organizationId: member.organizationId,
14069
+ role: member.role
14070
+ };
14071
+ return {
14072
+ accessToken: await signToken(secret, {
14073
+ ...tokenBase,
14074
+ type: "access"
14075
+ }, ACCESS_TOKEN_EXPIRY),
14076
+ refreshToken: await signToken(secret, {
14077
+ ...tokenBase,
14078
+ type: "refresh"
14079
+ }, REFRESH_TOKEN_EXPIRY)
14080
+ };
14081
+ }
13028
14082
  async function login(db, username, password, jwtSecretKey) {
13029
14083
  const [user] = await db.select().from(users).where(eq(users.username, username)).limit(1);
13030
14084
  if (!user || user.status !== "active") throw new UnauthorizedError("Invalid username or password");
13031
14085
  if (!await bcrypt.compare(password, user.passwordHash)) throw new UnauthorizedError("Invalid username or password");
13032
- const [member] = await db.select().from(members).where(eq(members.userId, user.id)).limit(1);
14086
+ const [member] = await db.select().from(members).where(and(eq(members.userId, user.id), eq(members.status, "active"))).limit(1);
13033
14087
  if (!member) throw new UnauthorizedError("No organization membership found");
14088
+ const tokens = await signTokensForMember(jwtSecretKey, {
14089
+ userId: user.id,
14090
+ memberId: member.id,
14091
+ organizationId: member.organizationId,
14092
+ role: member.role
14093
+ });
14094
+ await db.update(users).set({ updatedAt: /* @__PURE__ */ new Date() }).where(eq(users.id, user.id));
14095
+ return tokens;
14096
+ }
14097
+ async function refreshAccessToken(db, refreshToken, jwtSecretKey) {
13034
14098
  const secret = new TextEncoder().encode(jwtSecretKey);
13035
- const tokenBase = {
14099
+ let payload;
14100
+ try {
14101
+ const { payload: p } = await jwtVerify(refreshToken, secret);
14102
+ payload = p;
14103
+ } catch {
14104
+ throw new UnauthorizedError("Invalid or expired refresh token");
14105
+ }
14106
+ if (payload.type !== "refresh" || !payload.sub) throw new UnauthorizedError("Invalid token type");
14107
+ const [user] = await db.select({
14108
+ id: users.id,
14109
+ status: users.status
14110
+ }).from(users).where(eq(users.id, payload.sub)).limit(1);
14111
+ if (!user || user.status !== "active") throw new UnauthorizedError("User not found or suspended");
14112
+ const [member] = await db.select().from(members).where(and(eq(members.id, payload.memberId), eq(members.status, "active"))).limit(1);
14113
+ if (!member) throw new UnauthorizedError("Membership not found");
14114
+ return { accessToken: await signToken(secret, {
13036
14115
  sub: user.id,
13037
14116
  memberId: member.id,
13038
14117
  organizationId: member.organizationId,
14118
+ role: member.role,
14119
+ type: "access"
14120
+ }, ACCESS_TOKEN_EXPIRY) };
14121
+ }
14122
+ /**
14123
+ * Generate a short-lived connect token for CLI authentication.
14124
+ * The connect token carries the member's identity and can be exchanged
14125
+ * for full access+refresh tokens via exchangeConnectToken().
14126
+ *
14127
+ * `iss` (when supplied) is stamped into the JWT so the CLI can derive
14128
+ * the hub URL with no additional argument. Production servers must
14129
+ * always pass it; dev callers may omit and the CLI will require an
14130
+ * explicit `--server-url` (legacy form).
14131
+ */
14132
+ async function generateConnectToken(member, jwtSecretKey, iss) {
14133
+ const secret = new TextEncoder().encode(jwtSecretKey);
14134
+ const jti = randomUUID();
14135
+ const builder = new SignJWT({
14136
+ sub: member.userId,
14137
+ memberId: member.memberId,
14138
+ organizationId: member.organizationId,
14139
+ role: member.role,
14140
+ type: "connect"
14141
+ }).setProtectedHeader({ alg: "HS256" }).setIssuedAt().setJti(jti).setExpirationTime(CONNECT_TOKEN_EXPIRY);
14142
+ if (iss) builder.setIssuer(iss);
14143
+ return {
14144
+ token: await builder.sign(secret),
14145
+ expiresIn: 600
14146
+ };
14147
+ }
14148
+ /**
14149
+ * Exchange a connect token for full access+refresh tokens.
14150
+ * Validates the connect token, verifies the user is still active,
14151
+ * and issues a fresh token pair.
14152
+ */
14153
+ async function exchangeConnectToken(db, connectToken, jwtSecretKey) {
14154
+ const secret = new TextEncoder().encode(jwtSecretKey);
14155
+ let payload;
14156
+ try {
14157
+ const { payload: p } = await jwtVerify(connectToken, secret);
14158
+ payload = p;
14159
+ } catch {
14160
+ throw new UnauthorizedError("Invalid or expired connect token");
14161
+ }
14162
+ if (payload.type !== "connect" || !payload.sub || !payload.memberId) throw new UnauthorizedError("Invalid token type — expected connect token");
14163
+ const jti = payload.jti;
14164
+ if (jti) {
14165
+ if (consumedConnectJtis.has(jti)) throw new UnauthorizedError("Connect token has already been used");
14166
+ consumedConnectJtis.set(jti, Date.now());
14167
+ const cutoff = Date.now() - CONNECT_JTI_TTL_MS;
14168
+ for (const [k, ts] of consumedConnectJtis) if (ts < cutoff) consumedConnectJtis.delete(k);
14169
+ }
14170
+ const [user] = await db.select({
14171
+ id: users.id,
14172
+ status: users.status
14173
+ }).from(users).where(eq(users.id, payload.sub)).limit(1);
14174
+ if (!user || user.status !== "active") throw new UnauthorizedError("User not found or suspended");
14175
+ const [member] = await db.select().from(members).where(and(eq(members.id, payload.memberId), eq(members.status, "active"))).limit(1);
14176
+ if (!member) throw new UnauthorizedError("Membership not found");
14177
+ return signTokensForMember(jwtSecretKey, {
14178
+ userId: user.id,
14179
+ memberId: member.id,
14180
+ organizationId: member.organizationId,
13039
14181
  role: member.role
14182
+ });
14183
+ }
14184
+ /**
14185
+ * Third-party / local auth identities for a user. Models "how does this user
14186
+ * prove they are who they say they are". A single user MAY have multiple
14187
+ * identities (e.g. GitHub login + future email/password) but each
14188
+ * (provider, identifier) tuple maps to exactly one user.
14189
+ *
14190
+ * v1 supported shapes:
14191
+ * - GitHub OAuth: provider='github', identifier=<github numeric id>,
14192
+ * email=<primary>, credential_type=null
14193
+ * - Future Email + password: provider='email', identifier=<email>,
14194
+ * credential_type='password', credential_payload={ hash }
14195
+ * - Future Email + magic link: provider='email', identifier=<email>,
14196
+ * credential_type=null
14197
+ * - Future Webauthn / passkey: credential_type='webauthn',
14198
+ * credential_payload={ pubkey, counter }
14199
+ *
14200
+ * v1 explicitly does NOT support multi-factor on the same identity — the
14201
+ * (provider, identifier) UNIQUE constraint precludes two credential rows for
14202
+ * the same identifier. v2 splits credential_type / credential_payload into
14203
+ * a separate auth_credentials table; the migration is recorded in the
14204
+ * proposal so the upgrade path is unambiguous.
14205
+ *
14206
+ * The legacy `users.password_hash` column is preserved for backwards-compat
14207
+ * with self-host installs created before this milestone; new SaaS users get
14208
+ * a non-functional placeholder there and a real `auth_identities` row.
14209
+ */
14210
+ const authIdentities = pgTable("auth_identities", {
14211
+ id: text("id").primaryKey(),
14212
+ userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
14213
+ provider: text("provider").notNull(),
14214
+ identifier: text("identifier").notNull(),
14215
+ email: text("email"),
14216
+ verifiedAt: timestamp("verified_at", { withTimezone: true }),
14217
+ credentialType: text("credential_type"),
14218
+ credentialPayload: jsonb("credential_payload").$type(),
14219
+ metadata: jsonb("metadata").$type().notNull().default({}),
14220
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
14221
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
14222
+ }, (table) => [
14223
+ unique("uq_auth_identities_provider_identifier").on(table.provider, table.identifier),
14224
+ index("idx_auth_identities_user").on(table.userId),
14225
+ index("idx_auth_identities_email").on(table.email),
14226
+ uniqueIndex("uq_auth_identities_user_github").on(table.userId).where(sql`provider = 'github'`)
14227
+ ]);
14228
+ /**
14229
+ * Find or create the user backing a GitHub OAuth identity. Idempotent —
14230
+ * subsequent logins by the same `githubId` reuse the prior `user_id` row.
14231
+ *
14232
+ * SaaS users have no password. The legacy `users.password_hash` column is
14233
+ * NOT NULL (preserved for self-host), so we fill it with a non-functional
14234
+ * 32-byte random string. The bcrypt comparison in `authService.login`
14235
+ * treats it as a plain string and rejects every password — that's the
14236
+ * intended behaviour: SaaS users cannot fall back to password login.
14237
+ */
14238
+ async function findOrCreateUserFromGithub(db, profile) {
14239
+ const [existing] = await db.select({ userId: authIdentities.userId }).from(authIdentities).where(and(eq(authIdentities.provider, "github"), eq(authIdentities.identifier, profile.githubId))).limit(1);
14240
+ if (existing) {
14241
+ if (profile.email) await db.update(authIdentities).set({
14242
+ email: profile.email,
14243
+ updatedAt: /* @__PURE__ */ new Date()
14244
+ }).where(and(eq(authIdentities.provider, "github"), eq(authIdentities.identifier, profile.githubId)));
14245
+ return { userId: existing.userId };
14246
+ }
14247
+ const userId = uuidv7();
14248
+ const baseUsername = profile.login.toLowerCase();
14249
+ const placeholderHash = `oauth:${randomBytes(32).toString("base64url")}`;
14250
+ await insertWithUsernameRetry(db, baseUsername, async (tx, username) => {
14251
+ await tx.insert(users).values({
14252
+ id: userId,
14253
+ username,
14254
+ passwordHash: placeholderHash,
14255
+ displayName: profile.displayName?.trim() || profile.login,
14256
+ avatarUrl: profile.avatarUrl ?? null
14257
+ });
14258
+ await tx.insert(authIdentities).values({
14259
+ id: uuidv7(),
14260
+ userId,
14261
+ provider: "github",
14262
+ identifier: profile.githubId,
14263
+ email: profile.email,
14264
+ verifiedAt: /* @__PURE__ */ new Date(),
14265
+ metadata: { login: profile.login }
14266
+ });
14267
+ });
14268
+ return { userId };
14269
+ }
14270
+ /** Postgres `unique_violation` SQLSTATE — emitted when a UNIQUE constraint trips. */
14271
+ const PG_UNIQUE_VIOLATION$1 = "23505";
14272
+ /**
14273
+ * Pick a candidate username, attempt the caller's INSERT in a transaction,
14274
+ * and retry under a fresh disambiguator if the UNIQUE(users.username)
14275
+ * constraint trips. Two concurrent OAuth sign-ins for the same GitHub
14276
+ * `login` would otherwise let one INSERT win and the other 500 — the
14277
+ * race window between the pre-check `SELECT` and the `INSERT` is small but
14278
+ * non-zero in production. Retry budget is small; pathological storms fall
14279
+ * back to a fully-random suffix.
14280
+ */
14281
+ async function insertWithUsernameRetry(db, base, insert) {
14282
+ const [hit] = await db.select({ id: users.id }).from(users).where(eq(users.username, base)).limit(1);
14283
+ let candidate = hit ? `${base}-${randomBytes(2).toString("hex")}` : base;
14284
+ for (let attempt = 0; attempt < 4; attempt += 1) try {
14285
+ await db.transaction(async (tx) => {
14286
+ await insert(tx, candidate);
14287
+ });
14288
+ return;
14289
+ } catch (err) {
14290
+ if ((err?.code ?? err?.cause?.code) !== PG_UNIQUE_VIOLATION$1) throw err;
14291
+ candidate = `${base}-${randomBytes(2).toString("hex")}`;
14292
+ }
14293
+ candidate = `${base}-${uuidv7().slice(0, 12)}`;
14294
+ await db.transaction(async (tx) => {
14295
+ await insert(tx, candidate);
14296
+ });
14297
+ }
14298
+ const TOKEN_URL = "https://github.com/login/oauth/access_token";
14299
+ const USER_URL = "https://api.github.com/user";
14300
+ const USER_EMAILS_URL = "https://api.github.com/user/emails";
14301
+ /**
14302
+ * Exchange an OAuth code for an access token + fetch the user profile.
14303
+ *
14304
+ * The default `fetch` is overridable via `opts.fetcher` so tests can mock
14305
+ * the GitHub round-trip without standing up a fake server. The contract
14306
+ * the test fake must honor:
14307
+ * - First call: POST `${TOKEN_URL}` → returns `{ access_token: string }`
14308
+ * - Then GET `${USER_URL}` with `Authorization: Bearer …`
14309
+ * - Then GET `${USER_EMAILS_URL}` (only if `/user` returned no email)
14310
+ */
14311
+ async function exchangeCodeForProfile(config, code, redirectUri, opts = {}) {
14312
+ const fetcher = opts.fetcher ?? fetch;
14313
+ const tokenRes = await fetcher(TOKEN_URL, {
14314
+ method: "POST",
14315
+ headers: {
14316
+ Accept: "application/json",
14317
+ "Content-Type": "application/json"
14318
+ },
14319
+ body: JSON.stringify({
14320
+ client_id: config.clientId,
14321
+ client_secret: config.clientSecret,
14322
+ code,
14323
+ redirect_uri: redirectUri
14324
+ })
14325
+ });
14326
+ if (!tokenRes.ok) throw new Error(`GitHub token exchange failed (${tokenRes.status})`);
14327
+ const tokenJson = await tokenRes.json();
14328
+ if (!tokenJson.access_token) throw new Error(tokenJson.error ?? "GitHub token exchange returned no access_token");
14329
+ const userRes = await fetcher(USER_URL, { headers: {
14330
+ Authorization: `Bearer ${tokenJson.access_token}`,
14331
+ Accept: "application/vnd.github+json"
14332
+ } });
14333
+ if (!userRes.ok) throw new Error(`GitHub user fetch failed (${userRes.status})`);
14334
+ const user = await userRes.json();
14335
+ let email = user.email ?? null;
14336
+ if (!email) {
14337
+ const emailsRes = await fetcher(USER_EMAILS_URL, { headers: {
14338
+ Authorization: `Bearer ${tokenJson.access_token}`,
14339
+ Accept: "application/vnd.github+json"
14340
+ } });
14341
+ if (emailsRes.ok) {
14342
+ const emails = await emailsRes.json();
14343
+ email = (emails.find((e) => e.primary && e.verified) ?? emails.find((e) => e.verified))?.email ?? null;
14344
+ }
14345
+ }
14346
+ return {
14347
+ githubId: String(user.id),
14348
+ login: user.login,
14349
+ email,
14350
+ displayName: user.name ?? null,
14351
+ avatarUrl: user.avatar_url ?? null
13040
14352
  };
13041
- const accessToken = await signToken(secret, {
13042
- ...tokenBase,
13043
- type: "access"
13044
- }, ACCESS_TOKEN_EXPIRY);
13045
- const refreshToken = await signToken(secret, {
13046
- ...tokenBase,
13047
- type: "refresh"
13048
- }, REFRESH_TOKEN_EXPIRY);
13049
- await db.update(users).set({ updatedAt: /* @__PURE__ */ new Date() }).where(eq(users.id, user.id));
14353
+ }
14354
+ /** Insert (or reactivate) a `members` row for `userId` in `organizationId`. */
14355
+ async function ensureMembership(db, data) {
14356
+ const [existing] = await db.select().from(members).where(and(eq(members.userId, data.userId), eq(members.organizationId, data.organizationId))).limit(1);
14357
+ if (existing) {
14358
+ if (existing.status === "left") {
14359
+ await db.update(members).set({ status: "active" }).where(eq(members.id, existing.id));
14360
+ return {
14361
+ ...existing,
14362
+ status: "active"
14363
+ };
14364
+ }
14365
+ return existing;
14366
+ }
14367
+ return db.transaction(async (tx) => {
14368
+ const memberId = uuidv7();
14369
+ const agentName = sanitizeAgentName(data.username);
14370
+ const inboxId = `inbox_${uuidv7()}`;
14371
+ const agentUuid = uuidv7();
14372
+ await tx.insert(agents).values({
14373
+ uuid: agentUuid,
14374
+ name: agentName,
14375
+ organizationId: data.organizationId,
14376
+ type: "human",
14377
+ displayName: data.displayName,
14378
+ inboxId,
14379
+ source: "oauth",
14380
+ visibility: "organization",
14381
+ managerId: memberId
14382
+ });
14383
+ const [row] = await tx.insert(members).values({
14384
+ id: memberId,
14385
+ userId: data.userId,
14386
+ organizationId: data.organizationId,
14387
+ agentId: agentUuid,
14388
+ role: data.role,
14389
+ status: "active"
14390
+ }).returning();
14391
+ if (!row) throw new Error("Unexpected: INSERT RETURNING produced no row");
14392
+ return row;
14393
+ });
14394
+ }
14395
+ function sanitizeAgentName(login) {
14396
+ return login.toLowerCase().replace(/[^a-z0-9_-]/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "user";
14397
+ }
14398
+ /**
14399
+ * Create a fresh "personal team" org for a brand-new user, plus the
14400
+ * matching admin membership + 1:1 human agent. Slug strategy:
14401
+ *
14402
+ * - First try: `${login}-personal`
14403
+ * - On collision: append a 4-char hex disambiguator
14404
+ *
14405
+ * The display name is `{user}'s Personal Team` so it reads sensibly in the
14406
+ * UI; the user can rename via Settings later (proposal §"Personal team
14407
+ * visual降级").
14408
+ */
14409
+ async function createPersonalTeam(db, input) {
14410
+ const baseSlug = sanitizeOrgSlug(`${input.loginSeed}-personal`);
14411
+ const displayName = `${input.userDisplayName}'s Personal Team`;
14412
+ const orgId = uuidv7();
14413
+ return {
14414
+ organizationId: orgId,
14415
+ slug: await insertOrgWithSlugRetry(db, orgId, baseSlug, displayName),
14416
+ displayName,
14417
+ memberId: (await ensureMembership(db, {
14418
+ userId: input.userId,
14419
+ organizationId: orgId,
14420
+ role: "admin",
14421
+ displayName: input.userDisplayName,
14422
+ username: input.loginSeed
14423
+ })).id
14424
+ };
14425
+ }
14426
+ function sanitizeOrgSlug(raw) {
14427
+ return raw.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "team";
14428
+ }
14429
+ /** Postgres `unique_violation` SQLSTATE — `organizations.name` UNIQUE tripping. */
14430
+ const PG_UNIQUE_VIOLATION = "23505";
14431
+ /**
14432
+ * Attempt INSERT into `organizations` with `base` slug, retrying with a
14433
+ * disambiguator on UNIQUE constraint violations. Two concurrent OAuth
14434
+ * sign-ins for the same GitHub `login` would race here without retry —
14435
+ * pre-check `SELECT` followed by `INSERT` has a TOCTOU window the unique
14436
+ * constraint catches but the catch path needs to exist. Returns the slug
14437
+ * actually used.
14438
+ */
14439
+ async function insertOrgWithSlugRetry(db, orgId, base, displayName) {
14440
+ const [existing] = await db.select({ id: organizations.id }).from(organizations).where(eq(organizations.name, base)).limit(1);
14441
+ let candidate = existing ? `${base}-${randomBytes(2).toString("hex")}` : base;
14442
+ for (let attempt = 0; attempt < 4; attempt += 1) try {
14443
+ await db.insert(organizations).values({
14444
+ id: orgId,
14445
+ name: candidate,
14446
+ displayName
14447
+ });
14448
+ return candidate;
14449
+ } catch (err) {
14450
+ if ((err?.code ?? err?.cause?.code) !== PG_UNIQUE_VIOLATION) throw err;
14451
+ candidate = `${base}-${randomBytes(2).toString("hex")}`;
14452
+ }
14453
+ candidate = `${base}-${uuidv7().slice(0, 12)}`;
14454
+ await db.insert(organizations).values({
14455
+ id: orgId,
14456
+ name: candidate,
14457
+ displayName
14458
+ });
14459
+ return candidate;
14460
+ }
14461
+ /** List ACTIVE memberships (omit soft-deleted "left") for a user. */
14462
+ async function listActiveMemberships(db, userId) {
14463
+ return await db.select({
14464
+ memberId: members.id,
14465
+ organizationId: members.organizationId,
14466
+ role: members.role,
14467
+ orgName: organizations.name,
14468
+ orgDisplayName: organizations.displayName,
14469
+ createdAt: members.createdAt
14470
+ }).from(members).innerJoin(organizations, eq(members.organizationId, organizations.id)).where(and(eq(members.userId, userId), eq(members.status, "active"))).orderBy(desc(members.createdAt));
14471
+ }
14472
+ /**
14473
+ * Pick the most recently joined active membership — used after OAuth login
14474
+ * when the user already has at least one team but no `next` was specified.
14475
+ */
14476
+ async function pickPrimaryMembership(db, userId) {
14477
+ return (await listActiveMemberships(db, userId))[0] ?? null;
14478
+ }
14479
+ /**
14480
+ * Mark `members.status='left'` for the given member. v1 simplification:
14481
+ * no "must transfer admin" check — the proposal accepts the trade-off
14482
+ * (last admin allowed to leave, leaves an orphan team) and the cleanup is
14483
+ * a v2 sweep job.
14484
+ */
14485
+ async function leaveOrganization(db, memberId) {
14486
+ const [existing] = await db.select().from(members).where(eq(members.id, memberId)).limit(1);
14487
+ if (!existing) throw new NotFoundError(`Membership "${memberId}" not found`);
14488
+ if (existing.status === "left") return existing;
14489
+ await db.update(members).set({ status: "left" }).where(eq(members.id, memberId));
14490
+ return {
14491
+ ...existing,
14492
+ status: "left"
14493
+ };
14494
+ }
14495
+ /**
14496
+ * Self-service "create another team" (operator clicks "Create team" in the
14497
+ * org switcher). Caller is the new team's admin. Slug uniqueness is
14498
+ * enforced by the underlying organizations.name UNIQUE constraint.
14499
+ */
14500
+ async function selfCreateOrganization(db, data) {
14501
+ const [collision] = await db.select({ id: organizations.id }).from(organizations).where(eq(organizations.name, data.name)).limit(1);
14502
+ if (collision) throw new ConflictError(`Organization "${data.name}" already exists`);
14503
+ if (data.name === "default") throw new BadRequestError("\"default\" is a reserved organization name");
14504
+ const orgId = uuidv7();
14505
+ await db.insert(organizations).values({
14506
+ id: orgId,
14507
+ name: data.name,
14508
+ displayName: data.displayName
14509
+ });
13050
14510
  return {
13051
- accessToken,
13052
- refreshToken
14511
+ organizationId: orgId,
14512
+ memberId: (await ensureMembership(db, {
14513
+ userId: data.userId,
14514
+ organizationId: orgId,
14515
+ role: "admin",
14516
+ displayName: data.userDisplayName,
14517
+ username: data.username
14518
+ })).id,
14519
+ name: data.name,
14520
+ displayName: data.displayName
13053
14521
  };
13054
14522
  }
13055
- async function refreshAccessToken(db, refreshToken, jwtSecretKey) {
13056
- const secret = new TextEncoder().encode(jwtSecretKey);
13057
- let payload;
13058
- try {
13059
- const { payload: p } = await jwtVerify(refreshToken, secret);
13060
- payload = p;
13061
- } catch {
13062
- throw new UnauthorizedError("Invalid or expired refresh token");
13063
- }
13064
- if (payload.type !== "refresh" || !payload.sub) throw new UnauthorizedError("Invalid token type");
13065
- const [user] = await db.select({
13066
- id: users.id,
13067
- status: users.status
13068
- }).from(users).where(eq(users.id, payload.sub)).limit(1);
13069
- if (!user || user.status !== "active") throw new UnauthorizedError("User not found or suspended");
13070
- const [member] = await db.select().from(members).where(eq(members.id, payload.memberId)).limit(1);
13071
- if (!member) throw new UnauthorizedError("Membership not found");
13072
- return { accessToken: await signToken(secret, {
13073
- sub: user.id,
13074
- memberId: member.id,
13075
- organizationId: member.organizationId,
13076
- role: member.role,
13077
- type: "access"
13078
- }, ACCESS_TOKEN_EXPIRY) };
13079
- }
13080
14523
  /**
13081
- * Generate a short-lived connect token for CLI authentication.
13082
- * The connect token carries the member's identity and can be exchanged
13083
- * for full access+refresh tokens via exchangeConnectToken().
14524
+ * State-token signing for the GitHub OAuth dance.
14525
+ *
14526
+ * Flow:
14527
+ * 1. `/auth/github/start` mints a `state` JWT *and* an HttpOnly cookie
14528
+ * holding the same nonce. Both ride for ~10 minutes.
14529
+ * 2. GitHub redirects back to `/auth/github/callback?code=…&state=<jwt>`.
14530
+ * 3. Callback verifies the JWT (signature + expiry) AND that the cookie
14531
+ * nonce matches `payload.nonce`. The double check defeats the
14532
+ * classic login-CSRF where an attacker pre-signs a `start` with their
14533
+ * own GitHub account and tricks a victim's browser into completing
14534
+ * the callback under that identity.
14535
+ *
14536
+ * `next` rides inside the JWT so the caller's intended landing path can't
14537
+ * be tampered with mid-flight.
13084
14538
  */
13085
- async function generateConnectToken(member, jwtSecretKey) {
13086
- const secret = new TextEncoder().encode(jwtSecretKey);
13087
- const jti = randomUUID();
14539
+ const STATE_EXPIRY = "10m";
14540
+ const NONCE_BYTES = 24;
14541
+ const OAUTH_STATE_COOKIE = "oauth_state_nonce";
14542
+ /**
14543
+ * Sign a fresh state token + return the matching cookie nonce. Caller is
14544
+ * responsible for setting the cookie (HttpOnly + Secure in prod).
14545
+ */
14546
+ async function signOAuthState(jwtSecret, next) {
14547
+ const nonce = randomBytes(NONCE_BYTES).toString("base64url");
14548
+ const secret = new TextEncoder().encode(jwtSecret);
13088
14549
  return {
13089
14550
  token: await new SignJWT({
13090
- sub: member.userId,
13091
- memberId: member.memberId,
13092
- organizationId: member.organizationId,
13093
- role: member.role,
13094
- type: "connect"
13095
- }).setProtectedHeader({ alg: "HS256" }).setIssuedAt().setJti(jti).setExpirationTime(CONNECT_TOKEN_EXPIRY).sign(secret),
13096
- expiresIn: 600
14551
+ nonce,
14552
+ next
14553
+ }).setProtectedHeader({ alg: "HS256" }).setIssuedAt().setExpirationTime(STATE_EXPIRY).sign(secret),
14554
+ nonce
13097
14555
  };
13098
14556
  }
13099
14557
  /**
13100
- * Exchange a connect token for full access+refresh tokens.
13101
- * Validates the connect token, verifies the user is still active,
13102
- * and issues a fresh token pair.
14558
+ * Verify a state token. Returns the carried `next` on success. Throws
14559
+ * `Error` with the verification failure mode on rejection so the route
14560
+ * layer can map to 401.
14561
+ *
14562
+ * Cookie/nonce double-submit is mandatory — this is the CSRF defense.
14563
+ * `/dev-callback` does NOT call this function; it bypasses state entirely
14564
+ * (see `api/auth/github.ts`) because the dev shortcut also bypasses the
14565
+ * github.com round-trip that would have set a state cookie.
13103
14566
  */
13104
- async function exchangeConnectToken(db, connectToken, jwtSecretKey) {
13105
- const secret = new TextEncoder().encode(jwtSecretKey);
14567
+ async function verifyOAuthState(jwtSecret, token, cookieNonce) {
14568
+ const secret = new TextEncoder().encode(jwtSecret);
13106
14569
  let payload;
13107
14570
  try {
13108
- const { payload: p } = await jwtVerify(connectToken, secret);
14571
+ const { payload: p } = await jwtVerify(token, secret);
13109
14572
  payload = p;
13110
14573
  } catch {
13111
- throw new UnauthorizedError("Invalid or expired connect token");
14574
+ throw new Error("Invalid or expired OAuth state");
13112
14575
  }
13113
- if (payload.type !== "connect" || !payload.sub || !payload.memberId) throw new UnauthorizedError("Invalid token type — expected connect token");
13114
- const jti = payload.jti;
13115
- if (jti) {
13116
- if (consumedConnectJtis.has(jti)) throw new UnauthorizedError("Connect token has already been used");
13117
- consumedConnectJtis.set(jti, Date.now());
13118
- const cutoff = Date.now() - CONNECT_JTI_TTL_MS;
13119
- for (const [k, ts] of consumedConnectJtis) if (ts < cutoff) consumedConnectJtis.delete(k);
14576
+ if (typeof payload.nonce !== "string" || typeof payload.next !== "string") throw new Error("OAuth state payload malformed");
14577
+ if (!cookieNonce || cookieNonce !== payload.nonce) throw new Error("OAuth state nonce / cookie mismatch");
14578
+ return { next: payload.next };
14579
+ }
14580
+ /**
14581
+ * Resolve the hub's public-facing base URL.
14582
+ *
14583
+ * Precedence:
14584
+ * 1. `app.config.server.publicUrl` — explicit configuration. Required in
14585
+ * production (the boot check enforces it).
14586
+ * 2. The request's `Host` header (with `X-Forwarded-Proto` honored) —
14587
+ * dev fallback so local quickstart works without extra config.
14588
+ *
14589
+ * Result is normalized to drop trailing slashes so callers can append
14590
+ * paths with a single leading `/`.
14591
+ */
14592
+ function resolvePublicUrl(app, request) {
14593
+ const configured = app.config.server.publicUrl;
14594
+ if (configured && configured.length > 0) return configured.replace(/\/+$/, "");
14595
+ return `${pickHeader(request.headers["x-forwarded-proto"]) ?? request.protocol}://${pickHeader(request.headers["x-forwarded-host"]) ?? pickHeader(request.headers.host) ?? request.hostname}`.replace(/\/+$/, "");
14596
+ }
14597
+ function pickHeader(value) {
14598
+ if (Array.isArray(value)) return value[0];
14599
+ return value;
14600
+ }
14601
+ /**
14602
+ * Manual cookie helpers — we don't pull in `@fastify/cookie` because the
14603
+ * SaaS onboarding flow needs exactly one cookie (the OAuth state nonce).
14604
+ * Parser tolerates the standard `name=value; name2=value2` format.
14605
+ */
14606
+ function parseCookieHeader(header, name) {
14607
+ if (!header) return null;
14608
+ const raw = Array.isArray(header) ? header.join("; ") : header;
14609
+ for (const entry of raw.split(/;\s*/)) {
14610
+ const eq = entry.indexOf("=");
14611
+ if (eq < 0) continue;
14612
+ if (entry.slice(0, eq).trim() === name) return decodeURIComponent(entry.slice(eq + 1));
13120
14613
  }
13121
- const [user] = await db.select({
13122
- id: users.id,
13123
- status: users.status
13124
- }).from(users).where(eq(users.id, payload.sub)).limit(1);
13125
- if (!user || user.status !== "active") throw new UnauthorizedError("User not found or suspended");
13126
- const [member] = await db.select().from(members).where(eq(members.id, payload.memberId)).limit(1);
13127
- if (!member) throw new UnauthorizedError("Membership not found");
13128
- const tokenBase = {
13129
- sub: user.id,
13130
- memberId: member.id,
13131
- organizationId: member.organizationId,
13132
- role: member.role
13133
- };
13134
- return {
13135
- accessToken: await signToken(secret, {
13136
- ...tokenBase,
13137
- type: "access"
13138
- }, ACCESS_TOKEN_EXPIRY),
13139
- refreshToken: await signToken(secret, {
13140
- ...tokenBase,
13141
- type: "refresh"
13142
- }, REFRESH_TOKEN_EXPIRY)
13143
- };
14614
+ return null;
14615
+ }
14616
+ function buildCookie(opts) {
14617
+ const sameSite = opts.sameSite ?? "Lax";
14618
+ const parts = [
14619
+ `${opts.name}=${encodeURIComponent(opts.value)}`,
14620
+ "Path=/",
14621
+ "HttpOnly",
14622
+ `SameSite=${sameSite}`,
14623
+ `Max-Age=${opts.maxAge}`
14624
+ ];
14625
+ if (opts.secure) parts.push("Secure");
14626
+ if (opts.maxAge <= 0) parts.push("Expires=Thu, 01 Jan 1970 00:00:00 GMT");
14627
+ return parts.join("; ");
14628
+ }
14629
+ /**
14630
+ * GitHub OAuth surface. All routes are public (no member JWT required).
14631
+ *
14632
+ * Routes:
14633
+ * - GET /auth/github/start — sign state JWT + cookie + 302 to GitHub
14634
+ * - GET /auth/github/callback — verify state + exchange code → fragment
14635
+ * - GET /auth/github/dev-callback — dev-only stub (no GitHub round-trip)
14636
+ */
14637
+ async function githubOauthRoutes(app) {
14638
+ const oauthCfg = app.config.oauth?.github;
14639
+ if (!oauthCfg) app.log.info("GitHub OAuth not configured — /auth/github/start will return 503. Set FIRST_TREE_HUB_GITHUB_OAUTH_CLIENT_ID/_SECRET to enable.");
14640
+ app.get("/start", { config: { rateLimit: {
14641
+ max: 20,
14642
+ timeWindow: "1 minute"
14643
+ } } }, async (request, reply) => {
14644
+ const { next } = githubStartQuerySchema.parse(request.query);
14645
+ const safeNext = safeRedirectPath(next ?? null);
14646
+ if (!oauthCfg) return reply.status(503).send({ error: "GitHub OAuth is not configured on this hub" });
14647
+ const { token, nonce } = await signOAuthState(app.config.secrets.jwtSecret, safeNext);
14648
+ const isProd = process.env.NODE_ENV === "production";
14649
+ reply.header("Set-Cookie", buildCookie({
14650
+ name: OAUTH_STATE_COOKIE,
14651
+ value: nonce,
14652
+ maxAge: 600,
14653
+ secure: isProd
14654
+ }));
14655
+ const redirectUri = `${resolvePublicUrl(app, request)}/api/v1/auth/github/callback`;
14656
+ const params = new URLSearchParams({
14657
+ client_id: oauthCfg.clientId,
14658
+ redirect_uri: redirectUri,
14659
+ state: token,
14660
+ scope: "read:user user:email",
14661
+ allow_signup: "true"
14662
+ });
14663
+ return reply.redirect(`https://github.com/login/oauth/authorize?${params}`, 302);
14664
+ });
14665
+ app.get("/callback", async (request, reply) => {
14666
+ if (!oauthCfg) return reply.status(503).send({ error: "GitHub OAuth is not configured on this hub" });
14667
+ const { code, state } = githubCallbackQuerySchema.parse(request.query);
14668
+ const cookieNonce = parseCookieHeader(request.headers.cookie, OAUTH_STATE_COOKIE);
14669
+ let next;
14670
+ try {
14671
+ next = (await verifyOAuthState(app.config.secrets.jwtSecret, state, cookieNonce)).next;
14672
+ } catch (err) {
14673
+ const msg = err instanceof Error ? err.message : "OAuth state rejected";
14674
+ return reply.status(401).send({ error: msg });
14675
+ }
14676
+ reply.header("Set-Cookie", buildCookie({
14677
+ name: OAUTH_STATE_COOKIE,
14678
+ value: "",
14679
+ maxAge: 0,
14680
+ secure: process.env.NODE_ENV === "production"
14681
+ }));
14682
+ const redirectUri = `${resolvePublicUrl(app, request)}/api/v1/auth/github/callback`;
14683
+ let profile;
14684
+ try {
14685
+ profile = await exchangeCodeForProfile({
14686
+ clientId: oauthCfg.clientId,
14687
+ clientSecret: oauthCfg.clientSecret
14688
+ }, code, redirectUri);
14689
+ } catch (err) {
14690
+ const msg = err instanceof Error ? err.message : "GitHub exchange failed";
14691
+ app.log.warn({ err }, "github oauth code exchange failed");
14692
+ return reply.status(401).send({ error: msg });
14693
+ }
14694
+ return completeOauthFlow(app, request, reply, profile, next);
14695
+ });
14696
+ app.get("/dev-callback", async (request, reply) => {
14697
+ if (process.env.NODE_ENV === "production") return reply.status(404).send({ error: "Not found" });
14698
+ const params = githubDevCallbackQuerySchema.parse(request.query);
14699
+ const next = safeRedirectPath(params.next ?? null);
14700
+ return completeOauthFlow(app, request, reply, {
14701
+ githubId: params.githubId,
14702
+ login: params.login,
14703
+ email: params.email ?? null,
14704
+ displayName: params.displayName ?? params.login,
14705
+ avatarUrl: null
14706
+ }, next);
14707
+ });
14708
+ }
14709
+ async function completeOauthFlow(app, request, reply, profile, next) {
14710
+ const { userId } = await findOrCreateUserFromGithub(app.db, profile);
14711
+ let joinPath = "returning";
14712
+ const inviteMatch = /^\/invite\/([^/?#]+)/.exec(next);
14713
+ let memberInfo = null;
14714
+ if (inviteMatch?.[1]) {
14715
+ const token = inviteMatch[1];
14716
+ const inv = await findActiveByToken(app.db, token);
14717
+ if (!inv) return reply.status(404).send({ error: "Invitation not found or no longer valid" });
14718
+ const member = await ensureMembership(app.db, {
14719
+ userId,
14720
+ organizationId: inv.organizationId,
14721
+ role: inv.role === "admin" ? "admin" : "member",
14722
+ displayName: profile.displayName?.trim() || profile.login,
14723
+ username: profile.login
14724
+ });
14725
+ await recordRedemption(app.db, {
14726
+ invitationId: inv.id,
14727
+ userId,
14728
+ ip: request.ip,
14729
+ userAgent: request.headers["user-agent"] ?? null
14730
+ });
14731
+ memberInfo = {
14732
+ memberId: member.id,
14733
+ organizationId: member.organizationId,
14734
+ role: member.role === "admin" ? "admin" : "member"
14735
+ };
14736
+ joinPath = "invite";
14737
+ next = "/";
14738
+ } else {
14739
+ const primary = await pickPrimaryMembership(app.db, userId);
14740
+ if (primary) memberInfo = {
14741
+ memberId: primary.memberId,
14742
+ organizationId: primary.organizationId,
14743
+ role: primary.role === "admin" ? "admin" : "member"
14744
+ };
14745
+ else {
14746
+ const personal = await createPersonalTeam(app.db, {
14747
+ userId,
14748
+ loginSeed: profile.login,
14749
+ userDisplayName: profile.displayName?.trim() || profile.login
14750
+ });
14751
+ memberInfo = {
14752
+ memberId: personal.memberId,
14753
+ organizationId: personal.organizationId,
14754
+ role: "admin"
14755
+ };
14756
+ joinPath = "solo";
14757
+ next = "/";
14758
+ }
14759
+ }
14760
+ if (!memberInfo) return reply.status(500).send({ error: "Failed to resolve membership" });
14761
+ const tokens = await signTokensForMember(app.config.secrets.jwtSecret, {
14762
+ userId,
14763
+ memberId: memberInfo.memberId,
14764
+ organizationId: memberInfo.organizationId,
14765
+ role: memberInfo.role
14766
+ });
14767
+ const fragment = new URLSearchParams({
14768
+ access: tokens.accessToken,
14769
+ refresh: tokens.refreshToken,
14770
+ next,
14771
+ joinPath
14772
+ }).toString();
14773
+ return reply.redirect(`/auth/github/complete#${fragment}`, 302);
13144
14774
  }
13145
14775
  async function authRoutes(app) {
13146
14776
  const loginMax = app.config.rateLimit?.loginMax ?? 5;
@@ -13277,7 +14907,12 @@ async function healthzRoutes(app) {
13277
14907
  }
13278
14908
  });
13279
14909
  }
13280
- /** GET /me — returns current user + member + agent info. */
14910
+ /**
14911
+ * `/me` and self-service organization routes (mounted under the member
14912
+ * auth hook). The legacy `GET /me` shape is preserved + extended with
14913
+ * `wizard` and `inviteUrl` (admin only) so the web SPA can derive its
14914
+ * landing UI without an extra round-trip.
14915
+ */
13281
14916
  async function meRoutes(app) {
13282
14917
  app.get("/me", async (request) => {
13283
14918
  const m = requireMember(request);
@@ -13293,6 +14928,12 @@ async function meRoutes(app) {
13293
14928
  displayName: agents.displayName,
13294
14929
  inboxId: agents.inboxId
13295
14930
  }).from(agents).where(eq(agents.uuid, m.agentId)).limit(1);
14931
+ const wizardStep = await inferWizardStep(app, m);
14932
+ let inviteUrl = null;
14933
+ if (m.role === "admin") {
14934
+ const inv = await getActiveInvitation(app.db, m.organizationId);
14935
+ if (inv) inviteUrl = buildInviteUrl(resolvePublicUrl(app, request), inv.token);
14936
+ }
13296
14937
  return {
13297
14938
  user: user ?? null,
13298
14939
  member: {
@@ -13301,25 +14942,202 @@ async function meRoutes(app) {
13301
14942
  role: m.role,
13302
14943
  agentId: m.agentId
13303
14944
  },
13304
- agent: agent ?? null
14945
+ agent: agent ?? null,
14946
+ wizard: { step: wizardStep },
14947
+ inviteUrl
13305
14948
  };
13306
14949
  });
13307
14950
  /**
13308
14951
  * POST /connect-tokens — generate a short-lived connect token for CLI authentication.
13309
- * The token can be exchanged via POST /auth/connect-token for full credentials.
14952
+ * Stamped with `iss = server.publicUrl` (or the request host as a dev fallback)
14953
+ * so the CLI's `connect <token>` form can derive the hub URL with no extra arg.
14954
+ *
14955
+ * Rate-limited per-route at the same level as `/auth/login`: a "Copy
14956
+ * commands" double-click in the wizard mustn't burn through token slots,
14957
+ * but neither should a stolen access token mint unlimited connect tokens.
13310
14958
  */
13311
- app.post("/connect-tokens", async (request) => {
14959
+ const loginMax = app.config.rateLimit?.loginMax ?? 5;
14960
+ app.post("/connect-tokens", { config: { rateLimit: {
14961
+ max: loginMax,
14962
+ timeWindow: "1 minute"
14963
+ } } }, async (request) => {
13312
14964
  const m = requireMember(request);
14965
+ const issuer = resolvePublicUrl(app, request);
13313
14966
  const { token, expiresIn } = await generateConnectToken({
13314
14967
  userId: m.userId,
13315
14968
  memberId: m.memberId,
13316
14969
  organizationId: m.organizationId,
13317
14970
  role: m.role
13318
- }, app.config.secrets.jwtSecret);
14971
+ }, app.config.secrets.jwtSecret, issuer);
13319
14972
  return {
13320
14973
  token,
13321
14974
  expiresIn,
13322
- command: `first-tree-hub client connect ${`${request.headers["x-forwarded-proto"] ?? request.protocol}://${request.headers["x-forwarded-host"] ?? request.headers.host ?? request.hostname}`} --token ${token}`
14975
+ command: `first-tree-hub connect ${token}`
14976
+ };
14977
+ });
14978
+ app.get("/me/organizations", async (request) => {
14979
+ const m = requireMember(request);
14980
+ return (await listActiveMemberships(app.db, m.userId)).map((r) => ({
14981
+ id: r.organizationId,
14982
+ name: r.orgName,
14983
+ displayName: r.orgDisplayName,
14984
+ role: r.role
14985
+ }));
14986
+ });
14987
+ app.post("/me/organizations", async (request, reply) => {
14988
+ const m = requireMember(request);
14989
+ const body = createOrgFromMeSchema.parse(request.body);
14990
+ const [u] = await app.db.select({
14991
+ username: users.username,
14992
+ displayName: users.displayName
14993
+ }).from(users).where(eq(users.id, m.userId)).limit(1);
14994
+ if (!u) throw new NotFoundError("User not found");
14995
+ const created = await selfCreateOrganization(app.db, {
14996
+ userId: m.userId,
14997
+ userDisplayName: u.displayName,
14998
+ username: u.username,
14999
+ name: body.name,
15000
+ displayName: body.displayName
15001
+ });
15002
+ const tokens = await signTokensForMember(app.config.secrets.jwtSecret, {
15003
+ userId: m.userId,
15004
+ memberId: created.memberId,
15005
+ organizationId: created.organizationId,
15006
+ role: "admin"
15007
+ });
15008
+ return reply.status(201).send({
15009
+ organization: {
15010
+ id: created.organizationId,
15011
+ name: created.name,
15012
+ displayName: created.displayName,
15013
+ role: "admin"
15014
+ },
15015
+ tokens
15016
+ });
15017
+ });
15018
+ app.post("/me/organizations/join", { config: { rateLimit: {
15019
+ max: loginMax,
15020
+ timeWindow: "1 minute"
15021
+ } } }, async (request, reply) => {
15022
+ const m = requireMember(request);
15023
+ const body = joinByInvitationSchema.parse(request.body);
15024
+ const inv = await findActiveByToken(app.db, body.token);
15025
+ if (!inv) return reply.status(404).send({ error: "Invitation not found or no longer valid" });
15026
+ const [u] = await app.db.select({
15027
+ username: users.username,
15028
+ displayName: users.displayName
15029
+ }).from(users).where(eq(users.id, m.userId)).limit(1);
15030
+ if (!u) throw new NotFoundError("User not found");
15031
+ const member = await ensureMembership(app.db, {
15032
+ userId: m.userId,
15033
+ organizationId: inv.organizationId,
15034
+ role: inv.role === "admin" ? "admin" : "member",
15035
+ displayName: u.displayName,
15036
+ username: u.username
15037
+ });
15038
+ await recordRedemption(app.db, {
15039
+ invitationId: inv.id,
15040
+ userId: m.userId,
15041
+ ip: request.ip,
15042
+ userAgent: request.headers["user-agent"] ?? null
15043
+ });
15044
+ const tokens = await signTokensForMember(app.config.secrets.jwtSecret, {
15045
+ userId: m.userId,
15046
+ memberId: member.id,
15047
+ organizationId: member.organizationId,
15048
+ role: member.role
15049
+ });
15050
+ return reply.status(200).send({
15051
+ organizationId: member.organizationId,
15052
+ memberId: member.id,
15053
+ role: member.role,
15054
+ tokens
15055
+ });
15056
+ });
15057
+ app.post("/me/organizations/leave", async (request, reply) => {
15058
+ const m = requireMember(request);
15059
+ await leaveOrganization(app.db, m.memberId);
15060
+ return reply.status(204).send();
15061
+ });
15062
+ app.post("/auth/switch-org", async (request, reply) => {
15063
+ const m = requireMember(request);
15064
+ const body = switchOrgSchema.parse(request.body);
15065
+ 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);
15066
+ if (!target) throw new ForbiddenError("You do not belong to that organization");
15067
+ const tokens = await signTokensForMember(app.config.secrets.jwtSecret, {
15068
+ userId: m.userId,
15069
+ memberId: target.id,
15070
+ organizationId: target.organizationId,
15071
+ role: target.role
15072
+ });
15073
+ return reply.send(tokens);
15074
+ });
15075
+ }
15076
+ /**
15077
+ * Infer the wizard step from observable runtime state. Refer to
15078
+ * proposal §"Onboarding 状态推断" for the rationale.
15079
+ *
15080
+ * Note: we deliberately do NOT filter by `clients.status='connected'`
15081
+ * here. The original "fact-is-state" reading would have flapped between
15082
+ * `completed` and `connect` every time the user's client briefly went
15083
+ * offline — UX disaster (the onboarding modal would re-pop). "Ever
15084
+ * connected" (= a clients row exists at all for this user/org) is still
15085
+ * fact-derived: deleting the row really does rewind the wizard, and
15086
+ * that's the explicit reset path.
15087
+ */
15088
+ async function inferWizardStep(app, m) {
15089
+ 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);
15090
+ if (!hasClient) return "connect";
15091
+ 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);
15092
+ if (!hasAgent) return "create_agent";
15093
+ return "completed";
15094
+ }
15095
+ /**
15096
+ * Public route exported separately so it mounts BEFORE the member auth hook.
15097
+ * Just exposes the org's display name & slug for the unauthenticated `/invite/:token`
15098
+ * landing page.
15099
+ */
15100
+ async function publicInvitePreviewRoute(app) {
15101
+ const { previewInvitation } = await import("./invitation-BTlGMy0o-Coj07kYi.mjs");
15102
+ app.get("/:token/preview", async (request, reply) => {
15103
+ if (!request.params.token) throw new UnauthorizedError("Token required");
15104
+ const preview = await previewInvitation(app.db, request.params.token);
15105
+ return reply.send(preview);
15106
+ });
15107
+ }
15108
+ /**
15109
+ * Admin-only invitation routes — mounted under `/admin/organizations/:id/invitations`.
15110
+ */
15111
+ async function adminInvitationRoutes(app) {
15112
+ app.get("/", async (request) => {
15113
+ const m = requireMember(request);
15114
+ if (m.role !== "admin") throw new ForbiddenError("Admin role required");
15115
+ if (request.params.id !== m.organizationId) throw new ForbiddenError("Cannot inspect invitations for another organization");
15116
+ const inv = await ensureActiveInvitation(app.db, m.organizationId, m.userId);
15117
+ return {
15118
+ id: inv.id,
15119
+ organizationId: inv.organizationId,
15120
+ token: inv.token,
15121
+ inviteUrl: buildInviteUrl(resolvePublicUrl(app, request), inv.token),
15122
+ role: inv.role,
15123
+ createdAt: inv.createdAt.toISOString(),
15124
+ expiresAt: inv.expiresAt ? inv.expiresAt.toISOString() : null
15125
+ };
15126
+ });
15127
+ app.post("/rotate", async (request) => {
15128
+ const m = requireMember(request);
15129
+ if (m.role !== "admin") throw new ForbiddenError("Admin role required");
15130
+ if (request.params.id !== m.organizationId) throw new ForbiddenError("Cannot rotate invitations for another organization");
15131
+ const { rotateInvitation } = await import("./invitation-BTlGMy0o-Coj07kYi.mjs");
15132
+ const inv = await rotateInvitation(app.db, m.organizationId, m.userId);
15133
+ return {
15134
+ id: inv.id,
15135
+ organizationId: inv.organizationId,
15136
+ token: inv.token,
15137
+ inviteUrl: buildInviteUrl(resolvePublicUrl(app, request), inv.token),
15138
+ role: inv.role,
15139
+ createdAt: inv.createdAt.toISOString(),
15140
+ expiresAt: inv.expiresAt ? inv.expiresAt.toISOString() : null
13323
15141
  };
13324
15142
  });
13325
15143
  }
@@ -13959,10 +15777,13 @@ var schema_exports = /* @__PURE__ */ __exportAll({
13959
15777
  agentConfigs: () => agentConfigs,
13960
15778
  agentPresence: () => agentPresence,
13961
15779
  agents: () => agents,
15780
+ authIdentities: () => authIdentities,
13962
15781
  chatParticipants: () => chatParticipants,
13963
15782
  chats: () => chats,
13964
15783
  clients: () => clients,
13965
15784
  inboxEntries: () => inboxEntries,
15785
+ invitationRedemptions: () => invitationRedemptions,
15786
+ invitations: () => invitations,
13966
15787
  members: () => members,
13967
15788
  messages: () => messages,
13968
15789
  notifications: () => notifications,
@@ -14674,6 +16495,7 @@ function createConfigService(opts) {
14674
16495
  */
14675
16496
  function applyPatch(current, patch) {
14676
16497
  return {
16498
+ kind: current.kind,
14677
16499
  prompt: patch.prompt ?? current.prompt,
14678
16500
  model: patch.model ?? current.model,
14679
16501
  mcpServers: patch.mcpServers ?? current.mcpServers,
@@ -14699,13 +16521,26 @@ function createConfigService(opts) {
14699
16521
  async function readRow(agentId) {
14700
16522
  const [row] = await db.select().from(agentConfigs).where(eq(agentConfigs.agentId, agentId)).limit(1);
14701
16523
  if (!row) throw new NotFoundError(`Agent config "${agentId}" not found`);
14702
- return row;
16524
+ const payload = agentRuntimeConfigPayloadSchema$1.parse(row.payload);
16525
+ return {
16526
+ ...row,
16527
+ payload
16528
+ };
16529
+ }
16530
+ async function readRuntimeProviderFor(agentId) {
16531
+ const [row] = await db.select({ runtimeProvider: agents.runtimeProvider }).from(agents).where(eq(agents.uuid, agentId)).limit(1);
16532
+ if (!row) throw new NotFoundError(`Agent "${agentId}" not found`);
16533
+ return row.runtimeProvider;
14703
16534
  }
14704
16535
  async function commitWrite(agentId, patch, expectedVersion, updatedBy) {
14705
16536
  const current = await readRow(agentId);
14706
16537
  if (current.version !== expectedVersion) throw new ConflictError(`Agent config "${agentId}" version mismatch: expected ${expectedVersion}, got ${current.version}`);
14707
- const merged = applyPatch(current.payload, patch);
14708
- const validated = agentRuntimeConfigPayloadSchema$1.parse(merged);
16538
+ const provider = await readRuntimeProviderFor(agentId);
16539
+ const synced = {
16540
+ ...applyPatch(current.payload, patch),
16541
+ kind: provider
16542
+ };
16543
+ const validated = agentRuntimeConfigPayloadSchema$1.parse(synced);
14709
16544
  const [updated] = await db.update(agentConfigs).set({
14710
16545
  version: sql`${agentConfigs.version} + 1`,
14711
16546
  payload: validated,
@@ -14812,7 +16647,12 @@ function createConfigService(opts) {
14812
16647
  },
14813
16648
  async dryRun(agentId, patch) {
14814
16649
  const row = await readRow(agentId);
14815
- const next = agentRuntimeConfigPayloadSchema$1.parse(applyPatch(row.payload, patch));
16650
+ const provider = await readRuntimeProviderFor(agentId);
16651
+ const synced = {
16652
+ ...applyPatch(row.payload, patch),
16653
+ kind: provider
16654
+ };
16655
+ const next = agentRuntimeConfigPayloadSchema$1.parse(synced);
14816
16656
  const diff = computeDiff(row.payload, next);
14817
16657
  return {
14818
16658
  current: {
@@ -15217,6 +17057,8 @@ async function buildApp(config) {
15217
17057
  await api.register(healthRoutes);
15218
17058
  await api.register(githubWebhookRoutes, { prefix: "/webhooks" });
15219
17059
  await api.register(authRoutes, { prefix: "/auth" });
17060
+ await api.register(githubOauthRoutes, { prefix: "/auth/github" });
17061
+ await api.register(publicInvitePreviewRoute, { prefix: "/invite" });
15220
17062
  await api.register(contextTreeInfoRoutes, { prefix: "/context-tree" });
15221
17063
  await api.register(bootstrapConfigRoutes, { prefix: "/bootstrap" });
15222
17064
  await api.register(async (adminApp) => {
@@ -15277,6 +17119,10 @@ async function buildApp(config) {
15277
17119
  adminApp.addHook("onRequest", adminOnly);
15278
17120
  await adminApp.register(adminOrganizationRoutes);
15279
17121
  }, { prefix: "/admin/organizations" });
17122
+ await api.register(async (adminApp) => {
17123
+ adminApp.addHook("onRequest", memberAuth);
17124
+ await adminApp.register(adminInvitationRoutes);
17125
+ }, { prefix: "/admin/organizations/:id/invitations" });
15280
17126
  await api.register(async (adminApp) => {
15281
17127
  adminApp.addHook("onRequest", memberAuth);
15282
17128
  adminApp.addHook("onRequest", adminOnly);
@@ -15488,7 +17334,7 @@ async function startServer(options) {
15488
17334
  instanceId: `srv_${randomUUID().slice(0, 8)}`,
15489
17335
  commandVersion: COMMAND_VERSION
15490
17336
  };
15491
- const { initTelemetry, shutdownTelemetry } = await import("./observability-DDkJwSKv.mjs");
17337
+ const { initTelemetry, shutdownTelemetry } = await import("./observability-C08jUFsJ.mjs");
15492
17338
  await initTelemetry(serverConfig.observability.tracing, config.instanceId);
15493
17339
  const app = await buildApp(config);
15494
17340
  const shutdown = async () => {
@@ -15714,4 +17560,187 @@ function createExecuteUpdate({ managed }) {
15714
17560
  };
15715
17561
  }
15716
17562
  //#endregion
15717
- export { configureClientLoggerForService as $, installClientService as A, createOwner as B, checkNodeVersion as C, checkWebSocket as D, checkServerReachable as E, isDockerAvailable as F, setJsonMode as G, resolveReplyToFromEnv as H, stopPostgres as I, FirstTreeHubSDK as J, status as K, ClientRuntime as L, resolveCliInvocation as M, uninstallClientService as N, printResults as O, ensurePostgres as P, applyClientLoggerConfig as Q, handleClientOrgMismatch as R, checkDocker as S, checkServerHealth as T, blank as U, hasUser as V, print as W, SessionRegistry as X, SdkError as Y, cleanWorkspaces as Z, runMigrations as _, COMMAND_VERSION as a, checkClientConfig as b, promptMissingFields as c, onboardCheck as d, onboardCreate as f, migrateLocalAgentDirs as g, createApiNameResolver as h, startServer as i, isServiceSupported as j, getClientServiceStatus as k, formatCheckReport as l, runHomeMigration as m, declineUpdate as n, isInteractive as o, saveOnboardState as p, ClientOrgMismatchError$1 as q, promptUpdate as r, promptAddAgent as s, createExecuteUpdate as t, loadOnboardState as u, checkAgentConfigs as v, checkServerConfig as w, checkDatabase as x, checkBackgroundService as y, rotateClientIdWithBackup as z };
17563
+ //#region src/commands/saas-connect.ts
17564
+ /**
17565
+ * @internal
17566
+ * Decode a JWT payload without verifying its signature. Used only by the
17567
+ * CLI's account-switch prompt and the URL-derivation helper below. Not
17568
+ * re-exported from `packages/command/src/index.ts` — external consumers
17569
+ * should call `deriveHubUrlFromToken` instead.
17570
+ */
17571
+ function decodeJwtPayload(token) {
17572
+ try {
17573
+ const parts = token.split(".");
17574
+ if (parts.length !== 3 || !parts[1]) return null;
17575
+ const raw = Buffer.from(parts[1], "base64url").toString();
17576
+ const obj = JSON.parse(raw);
17577
+ if (typeof obj !== "object" || obj === null) return null;
17578
+ return obj;
17579
+ } catch {
17580
+ return null;
17581
+ }
17582
+ }
17583
+ var HubUrlDerivationError = class extends Error {
17584
+ constructor(code, message) {
17585
+ super(message);
17586
+ this.code = code;
17587
+ this.name = "HubUrlDerivationError";
17588
+ }
17589
+ };
17590
+ /**
17591
+ * Derive the hub URL from a connect token's `iss` claim. Throws
17592
+ * `HubUrlDerivationError` when the claim is missing or malformed — we
17593
+ * *never* fall back to a default URL because that would let a stale
17594
+ * connect token from one environment silently re-target another (prod →
17595
+ * staging foot-gun).
17596
+ *
17597
+ * The action handler maps the thrown error to a `fail()` exit so this
17598
+ * function stays unit-testable without spawning a subprocess.
17599
+ */
17600
+ function deriveHubUrlFromToken(token) {
17601
+ const payload = decodeJwtPayload(token);
17602
+ if (!payload) throw new HubUrlDerivationError("INVALID_TOKEN", "Connect token is not a valid JWT. Generate a new one from your Hub web console.");
17603
+ const iss = payload.iss;
17604
+ if (typeof iss !== "string" || iss.length === 0) throw new HubUrlDerivationError("TOKEN_MISSING_ISS", "Connect token does not carry an issuer (`iss` claim). Generate a new token from a Hub running v0.10+.");
17605
+ if (!/^https?:\/\//i.test(iss)) throw new HubUrlDerivationError("TOKEN_BAD_ISS", `Connect token issuer "${iss}" is not an http(s) URL. Generate a new token.`);
17606
+ return iss.replace(/\/+$/, "");
17607
+ }
17608
+ async function promptReplaceOrCancel(newMemberId) {
17609
+ const existing = loadCredentials();
17610
+ if (!existing) return "proceed";
17611
+ const existingPayload = decodeJwtPayload(existing.accessToken);
17612
+ const existingMemberId = typeof existingPayload?.memberId === "string" ? existingPayload.memberId : null;
17613
+ if (existingMemberId && existingMemberId === newMemberId) return "proceed";
17614
+ const existingMember = existingMemberId ? `member ${existingMemberId.slice(0, 8)}` : "unknown account";
17615
+ const serviceStatus = getClientServiceStatus();
17616
+ const serviceLine = serviceStatus.state === "active" ? `running (${serviceStatus.detail ?? "live"})` : serviceStatus.state === "inactive" ? `installed but not running${serviceStatus.detail ? ` — ${serviceStatus.detail}` : ""}` : "not installed";
17617
+ print.line("\n ⚠️ This computer is already connected under another account.\n\n");
17618
+ print.line(` Existing account: ${existingMember}\n`);
17619
+ print.line(` Server: ${existing.serverUrl}\n`);
17620
+ print.line(` Background service: ${serviceLine}\n\n`);
17621
+ print.line(" Replacing only affects THIS computer. Server-side data is untouched.\n\n");
17622
+ return await select({
17623
+ message: "How would you like to continue?",
17624
+ choices: [{
17625
+ name: "Replace — log out the other account and set up this one",
17626
+ value: "replace"
17627
+ }, {
17628
+ name: "Cancel — keep the existing setup",
17629
+ value: "cancel"
17630
+ }]
17631
+ }) === "replace" ? "proceed" : "cancel";
17632
+ }
17633
+ async function exchangeToken(url, token) {
17634
+ const res = await fetch(`${url}/api/v1/auth/connect-token`, {
17635
+ method: "POST",
17636
+ headers: { "Content-Type": "application/json" },
17637
+ body: JSON.stringify({ token }),
17638
+ signal: AbortSignal.timeout(1e4)
17639
+ });
17640
+ if (!res.ok) fail("AUTH_ERROR", (await res.json().catch(() => ({}))).error ?? `Token exchange failed (HTTP ${res.status})`, 1);
17641
+ return await res.json();
17642
+ }
17643
+ /**
17644
+ * Top-level `first-tree-hub connect <token>`. Single positional, no flags,
17645
+ * no env-var override — the connect token's `iss` claim carries the hub
17646
+ * URL so prod / staging / local environments are tagged at issuance and
17647
+ * the operator can never accidentally cross-target.
17648
+ */
17649
+ function registerSaaSConnectCommand(program) {
17650
+ program.command("connect <token>").description("Connect this computer to the Hub using a token from the web console").option("--no-service", "Skip background service install (runs inline until Ctrl+C)").action(async (token, options) => {
17651
+ try {
17652
+ let url;
17653
+ try {
17654
+ url = deriveHubUrlFromToken(token);
17655
+ } catch (err) {
17656
+ if (err instanceof HubUrlDerivationError) fail(err.code, err.message, 1);
17657
+ throw err;
17658
+ }
17659
+ const payload = decodeJwtPayload(token);
17660
+ const newMemberId = typeof payload?.memberId === "string" ? payload.memberId : null;
17661
+ if (newMemberId) {
17662
+ if (await promptReplaceOrCancel(newMemberId) === "cancel") {
17663
+ print.line("\n Cancelled. Existing setup untouched.\n");
17664
+ return;
17665
+ }
17666
+ }
17667
+ const tokens = await exchangeToken(url, token);
17668
+ setConfigValue(join(DEFAULT_CONFIG_DIR, "client.yaml"), "server.url", url);
17669
+ print.line(`\n ✓ Hub: ${url}\n`);
17670
+ saveCredentials({
17671
+ ...tokens,
17672
+ serverUrl: url
17673
+ });
17674
+ print.line(" ✓ Authenticated\n");
17675
+ resetConfig();
17676
+ resetConfigMeta();
17677
+ const config = await initConfig({
17678
+ schema: clientConfigSchema,
17679
+ role: "client"
17680
+ });
17681
+ print.line(` ✓ Computer registered (id: ${config.client.id})\n`);
17682
+ if (options.service !== false && isServiceSupported()) {
17683
+ const info = installClientService();
17684
+ print.line(` ✓ Background service installed (${info.platform}) — you may close this terminal.\n`);
17685
+ print.line(` Logs: ${info.logDir}\n\n`);
17686
+ return;
17687
+ }
17688
+ if (options.service === false) print.line(" (--no-service) running inline — Ctrl+C to stop\n");
17689
+ else print.line(` Background service not supported on ${process.platform}; running inline.\n`);
17690
+ const agentsDir = join(DEFAULT_CONFIG_DIR, "agents");
17691
+ try {
17692
+ await migrateLocalAgentDirs({
17693
+ agentsDir,
17694
+ workspacesDir: join(DEFAULT_DATA_DIR$1, "workspaces"),
17695
+ sessionsDir: join(DEFAULT_DATA_DIR$1, "sessions"),
17696
+ resolver: createApiNameResolver(config.server.url, () => ensureFreshAccessToken())
17697
+ });
17698
+ } catch (err) {
17699
+ const msg = err instanceof Error ? err.message : String(err);
17700
+ print.status("⚠️", `agent-dir migration skipped: ${msg}`);
17701
+ }
17702
+ const agents = loadAgents({
17703
+ schema: agentConfigSchema,
17704
+ agentsDir
17705
+ });
17706
+ const runtime = new ClientRuntime(config.server.url, config.client.id, {
17707
+ currentVersion: COMMAND_VERSION,
17708
+ update: {
17709
+ updateConfig: config.update,
17710
+ prompt: promptUpdate,
17711
+ executeUpdate: createExecuteUpdate({ managed: false })
17712
+ }
17713
+ });
17714
+ for (const [name, agentConfig] of agents) runtime.addAgent(name, agentConfig);
17715
+ await runtime.start();
17716
+ runtime.watchAgentsDir(agentsDir);
17717
+ const shutdown = async () => {
17718
+ print.line("\n Shutting down...\n");
17719
+ runtime.unwatchAgentsDir();
17720
+ await runtime.stop();
17721
+ process.exit(0);
17722
+ };
17723
+ process.on("SIGINT", () => void shutdown());
17724
+ process.on("SIGTERM", () => void shutdown());
17725
+ await new Promise(() => {});
17726
+ } catch (error) {
17727
+ if (error.name === "ExitPromptError") {
17728
+ print.line("\n Cancelled.\n");
17729
+ return;
17730
+ }
17731
+ if (error instanceof ClientOrgMismatchError) await handleClientOrgMismatch(error, {
17732
+ managed: false,
17733
+ configDir: DEFAULT_CONFIG_DIR,
17734
+ rerunCommand: "first-tree-hub connect <token>"
17735
+ });
17736
+ const msg = error instanceof Error ? error.message : String(error);
17737
+ print.line(` Error: ${msg}\n`);
17738
+ process.exit(1);
17739
+ } finally {
17740
+ resetConfig();
17741
+ resetConfigMeta();
17742
+ }
17743
+ });
17744
+ }
17745
+ //#endregion
17746
+ 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 };