@agent-team-foundation/first-tree-hub 0.10.2 → 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.
@@ -1,7 +1,7 @@
1
1
  import { m as __toESM } from "./esm-CYu4tXXn.mjs";
2
2
  import { _ as withSpan, a as endWsConnectionSpan, b as require_pino, c as messageAttrs, d as rootLogger$1, g as startWsConnectionSpan, i as currentTraceId, n as applyLoggerConfig, o as getFastifyOtelPlugin, p as setWsConnectionAttrs, r as createLogger$1, t as adapterAttrs, u as observabilityPlugin, v as withWsMessageSpan, y as FIRST_TREE_HUB_ATTR } from "./observability-DPyf745N-BSc8QNcR.mjs";
3
- 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-Ca5Fiqz6.mjs";
4
- import { $ as sendMessageSchema, A as createOrganizationSchema, B as isRedactedEnvValue, C as connectTokenExchangeSchema, D as createChatSchema, E as createAgentSchema, F as githubCallbackQuerySchema, G as messageSourceSchema$1, H as joinByInvitationSchema, I as githubDevCallbackQuerySchema, J as refreshTokenSchema, K as notificationQuerySchema, L as githubStartQuerySchema, M as delegateFeishuUserSchema, N as dryRunAgentRuntimeConfigSchema, O as createMemberSchema, P as extractMentions, Q as selfServiceFeishuBotSchema, R as imageInlineContentSchema, S as clientRegisterSchema, T as createAdapterMappingSchema, U as linkTaskChatSchema, V as isReservedAgentName$1, W as loginSchema, X as safeRedirectPath, Y as runtimeStateMessageSchema, Z as scanMentionTokens, _ as adminUpdateTaskSchema, a as AGENT_STATUSES, at as sessionStateMessageSchema, b as agentRuntimeConfigPayloadSchema$1, c as DEFAULT_AGENT_RUNTIME_CONFIG_PAYLOAD, ct as updateAdapterConfigSchema, d as TASK_HEALTH_SIGNALS, dt as updateChatSchema, et as sendToAgentSchema, f as TASK_STATUSES, ft as updateMemberSchema, g as adminCreateTaskSchema, gt as wsAuthFrameSchema, h as addParticipantSchema, ht as updateTaskStatusSchema, i as AGENT_SOURCES, it as sessionReconcileRequestSchema, j as createTaskSchema, k as createOrgFromMeSchema, l as SYSTEM_CONFIG_DEFAULTS, lt as updateAgentRuntimeConfigSchema, m as WS_AUTH_FRAME_TIMEOUT_MS, mt as updateSystemConfigSchema, n as AGENT_NAME_REGEX$1, nt as sessionEventMessageSchema, o as AGENT_TYPES, ot as switchOrgSchema, p as TASK_TERMINAL_STATUSES, pt as updateOrganizationSchema, q as paginationQuerySchema, r as AGENT_SELECTOR_HEADER$1, rt as sessionEventSchema$1, s as AGENT_VISIBILITY, st as taskListQuerySchema, t as AGENT_BIND_REJECT_REASONS, tt as sessionCompletionMessageSchema, u as TASK_CREATOR_TYPES, ut as updateAgentSchema, v as agentBindRequestSchema, w as createAdapterConfigSchema, x as agentTypeSchema$1, y as agentPinnedMessageSchema$1, z as inboxPollQuerySchema } from "./dist-CLiN7cVS.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
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";
6
6
  import { createRequire } from "node:module";
7
7
  import { ZodError, z } from "zod";
@@ -16,13 +16,14 @@ import { mkdir, writeFile } from "node:fs/promises";
16
16
  import { parse, stringify } from "yaml";
17
17
  import { query } from "@anthropic-ai/claude-agent-sdk";
18
18
  import { execFileSync, execSync, spawn, spawnSync } from "node:child_process";
19
+ import { Codex } from "@openai/codex-sdk";
20
+ import { fileURLToPath } from "node:url";
19
21
  import * as semver from "semver";
20
22
  import bcrypt from "bcrypt";
21
23
  import { and, asc, count, desc, eq, gt, inArray, isNotNull, isNull, lt, ne, or, sql } from "drizzle-orm";
22
24
  import { drizzle } from "drizzle-orm/postgres-js";
23
25
  import postgres from "postgres";
24
26
  import { confirm, input, password, select } from "@inquirer/prompts";
25
- import { fileURLToPath } from "node:url";
26
27
  import { migrate } from "drizzle-orm/postgres-js/migrator";
27
28
  import { bigserial, boolean, index, integer, jsonb, pgTable, primaryKey, serial, text, timestamp, unique, uniqueIndex } from "drizzle-orm/pg-core";
28
29
  import cors from "@fastify/cors";
@@ -393,7 +394,8 @@ z.enum([
393
394
  "not_owned",
394
395
  "agent_suspended",
395
396
  "wrong_org",
396
- "unknown_agent"
397
+ "unknown_agent",
398
+ "runtime_provider_mismatch"
397
399
  ]);
398
400
  /** Header used on agent-scoped HTTP calls to select which managed agent the JWT acts as. */
399
401
  const AGENT_SELECTOR_HEADER = "x-agent-id";
@@ -421,6 +423,7 @@ z.object({
421
423
  }),
422
424
  clients: z.number().int()
423
425
  });
426
+ const runtimeProviderSchema = z.enum(["claude-code", "codex"]);
424
427
  const agentTypeSchema = z.enum([
425
428
  "human",
426
429
  "personal_assistant",
@@ -462,7 +465,8 @@ z.object({
462
465
  visibility: agentVisibilitySchema.optional(),
463
466
  metadata: z.record(z.string(), z.unknown()).optional(),
464
467
  managerId: z.string().optional(),
465
- clientId: z.string().min(1).max(100).optional()
468
+ clientId: z.string().min(1).max(100).optional(),
469
+ runtimeProvider: runtimeProviderSchema.optional()
466
470
  });
467
471
  z.object({
468
472
  type: agentTypeSchema.optional(),
@@ -473,6 +477,11 @@ z.object({
473
477
  managerId: z.string().nullable().optional(),
474
478
  clientId: z.string().min(1).max(100).nullable().optional()
475
479
  });
480
+ z.object({
481
+ clientId: z.string().min(1).max(100),
482
+ runtimeProvider: runtimeProviderSchema,
483
+ force: z.boolean().optional()
484
+ });
476
485
  z.object({
477
486
  uuid: z.string(),
478
487
  name: z.string().nullable(),
@@ -487,6 +496,7 @@ z.object({
487
496
  metadata: z.record(z.string(), z.unknown()),
488
497
  managerId: z.string().nullable(),
489
498
  clientId: z.string().nullable(),
499
+ runtimeProvider: runtimeProviderSchema,
490
500
  presenceStatus: presenceStatusSchema.optional(),
491
501
  createdAt: z.string(),
492
502
  updatedAt: z.string()
@@ -506,14 +516,16 @@ const agentPinnedMessageSchema = z.object({
506
516
  agentId: z.string(),
507
517
  name: z.string().nullable(),
508
518
  displayName: z.string(),
509
- agentType: agentTypeSchema
519
+ agentType: agentTypeSchema,
520
+ runtimeProvider: runtimeProviderSchema
510
521
  });
511
522
  /**
512
- * Agent runtime configuration — M1 (Claude Code only).
523
+ * Agent runtime configuration.
513
524
  *
514
525
  * Defines the 5 user-tunable field groups that the Hub centrally manages
515
526
  * and pushes down to the client runtime: prompt append, model, MCP servers,
516
- * 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.
517
529
  *
518
530
  * NOTE: do not co-locate with `packages/shared/src/config/` — that namespace
519
531
  * is reserved for the local YAML config (`agent.yaml` / server / client) and
@@ -557,9 +569,11 @@ const gitRepoSchema = z.object({
557
569
  localPath: z.string().min(1).optional()
558
570
  });
559
571
  /**
560
- * Base shape (no refinements) used for `.partial()` derivations such as the
561
- * PATCH payload schema. Zod 4 forbids `.partial()` on a refined object, so we
562
- * 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.
563
577
  */
564
578
  const agentRuntimeConfigPayloadShape = z.object({
565
579
  prompt: promptConfigSchema.default({ append: "" }),
@@ -568,6 +582,17 @@ const agentRuntimeConfigPayloadShape = z.object({
568
582
  env: z.array(envEntrySchema).default([]),
569
583
  gitRepos: z.array(gitRepoSchema).default([])
570
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]);
571
596
  const payloadDuplicatesRefinement = (payload, ctx) => {
572
597
  const seenMcp = /* @__PURE__ */ new Set();
573
598
  payload.mcpServers.forEach((server, idx) => {
@@ -612,7 +637,21 @@ const payloadDuplicatesRefinement = (payload, ctx) => {
612
637
  seenPaths.add(path);
613
638
  });
614
639
  };
615
- 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
+ });
616
655
  const agentRuntimeConfigSchema = z.object({
617
656
  agentId: z.string(),
618
657
  version: z.number().int().positive(),
@@ -721,6 +760,37 @@ z.object({
721
760
  os: z.string().max(50).optional(),
722
761
  sdkVersion: z.string().max(50).optional()
723
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 });
724
794
  z.object({
725
795
  limit: z.coerce.number().int().min(1).max(100).default(20),
726
796
  cursor: z.string().optional()
@@ -1410,8 +1480,7 @@ defineConfig({
1410
1480
  clientSecret: field(z.string(), {
1411
1481
  env: "FIRST_TREE_HUB_GITHUB_OAUTH_CLIENT_SECRET",
1412
1482
  secret: true
1413
- }),
1414
- devCallbackEnabled: field(z.boolean().default(false), { env: "FIRST_TREE_HUB_GITHUB_OAUTH_DEV_CALLBACK" })
1483
+ })
1415
1484
  }) }),
1416
1485
  cors: optional({ origin: field(z.string(), { env: "FIRST_TREE_HUB_CORS_ORIGIN" }) }),
1417
1486
  rateLimit: optional({
@@ -1536,6 +1605,25 @@ var FirstTreeHubSDK = class {
1536
1605
  async fetchAgentConfig() {
1537
1606
  return this.requestJson("/api/v1/agent/config");
1538
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
+ }
1539
1627
  async isHubReachable(timeoutMs = 3e3) {
1540
1628
  try {
1541
1629
  const url = `${this._baseUrl}/api/v1/health`;
@@ -2175,13 +2263,23 @@ function getHandlerFactory(type) {
2175
2263
  }
2176
2264
  join(DEFAULT_DATA_DIR, "context-tree");
2177
2265
  /**
2178
- * 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).
2179
2275
  *
2180
- * Writes identity.json, context/self.md (if context tree available), and tools.md.
2181
- * 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().
2182
2280
  */
2183
2281
  function bootstrapWorkspace(options) {
2184
- const { workspacePath, identity, contextTreePath, serverUrl, chatId } = options;
2282
+ const { workspacePath, identity, contextTreePath, serverUrl, chatId, briefing } = options;
2185
2283
  const agentDir = join(workspacePath, ".agent");
2186
2284
  const contextDir = join(agentDir, "context");
2187
2285
  if (existsSync(contextDir)) rmSync(contextDir, {
@@ -2207,6 +2305,8 @@ function bootstrapWorkspace(options) {
2207
2305
  if (existsSync(rootNodePath)) copyFileSync(rootNodePath, join(contextDir, "domain-map.md"));
2208
2306
  }
2209
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");
2210
2310
  }
2211
2311
  function defaultInstallExec(command, args, options) {
2212
2312
  execFileSync(command, args, {
@@ -2932,7 +3032,7 @@ function splitPathExt(pathext) {
2932
3032
  }
2933
3033
  const MAX_RETRIES = 2;
2934
3034
  const TOOL_RESULT_PREVIEW_LIMIT = 400;
2935
- const ASSISTANT_TEXT_EVENT_LIMIT = 8e3;
3035
+ const ASSISTANT_TEXT_EVENT_LIMIT$1 = 8e3;
2936
3036
  const SUPPORTED_IMAGE_MIMES = new Set(SUPPORTED_IMAGE_MIMES$1);
2937
3037
  const MIME_TO_EXT = {
2938
3038
  "image/png": "png",
@@ -3058,7 +3158,7 @@ function createToolCallProcessor(emit) {
3058
3158
  if (text.length === 0) continue;
3059
3159
  emit({
3060
3160
  kind: "assistant_text",
3061
- payload: { text: text.slice(0, ASSISTANT_TEXT_EVENT_LIMIT) }
3161
+ payload: { text: text.slice(0, ASSISTANT_TEXT_EVENT_LIMIT$1) }
3062
3162
  });
3063
3163
  } else if (isThinkingBlock(block)) emit({
3064
3164
  kind: "thinking",
@@ -3602,6 +3702,457 @@ function generateClaudeMd(workspacePath, identity, contextTreePath) {
3602
3702
  }
3603
3703
  writeFileSync(join(workspacePath, "CLAUDE.md"), sections.join("\n"), "utf-8");
3604
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
+ };
3605
4156
  /** Register all built-in handlers. Call once at startup. */
3606
4157
  function registerBuiltinHandlers() {
3607
4158
  const resolution = resolveClaudeCodeExecutable();
@@ -3611,6 +4162,7 @@ function registerBuiltinHandlers() {
3611
4162
  ...config,
3612
4163
  claudeCodeExecutable: resolution.path
3613
4164
  }));
4165
+ registerHandler("codex", (config) => createCodexHandler(config));
3614
4166
  }
3615
4167
  function createAgentConfigCache(opts) {
3616
4168
  const { sdk } = opts;
@@ -3645,6 +4197,7 @@ function createAgentConfigCache(opts) {
3645
4197
  agentId,
3646
4198
  version: 0,
3647
4199
  payload: {
4200
+ kind: "claude-code",
3648
4201
  prompt: { append: "" },
3649
4202
  model: "",
3650
4203
  mcpServers: [],
@@ -4662,6 +5215,226 @@ var AgentSlot = class {
4662
5215
  }
4663
5216
  };
4664
5217
  /**
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.
5223
+ */
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
+ }
5266
+ /**
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).
5272
+ */
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
+ /**
4665
5438
  * Runtime-wide constants (Step 7 + Step 11).
4666
5439
  *
4667
5440
  * After Step 11 these values are fixed at code level — the previously
@@ -5285,7 +6058,7 @@ var ClientRuntime = class {
5285
6058
  });
5286
6059
  const yaml = stringify({
5287
6060
  agentId: message.agentId,
5288
- runtime: "claude-code"
6061
+ runtime: message.runtimeProvider
5289
6062
  });
5290
6063
  writeFileSync(join(agentDir, "agent.yaml"), yaml, { mode: 384 });
5291
6064
  print.check(true, `auto-added agent "${localName}"`, `${message.agentId} (from server push)`);
@@ -6598,7 +7371,7 @@ async function onboardCreate(args) {
6598
7371
  }
6599
7372
  const runtimeAgent = args.type === "human" ? args.assistant : args.id;
6600
7373
  if (args.feishuBotAppId && args.feishuBotAppSecret) {
6601
- const { bindFeishuBot } = await import("./feishu-FTWnoOsc.mjs").then((n) => n.r);
7374
+ const { bindFeishuBot } = await import("./feishu-Boy3n8CT.mjs").then((n) => n.r);
6602
7375
  const targetAgentUuid = args.type === "human" ? assistantUuid : primary.uuid;
6603
7376
  if (!targetAgentUuid) print.line(`Warning: Cannot bind Feishu bot — no runtime agent available for "${args.id}".\n`);
6604
7377
  else {
@@ -6761,17 +7534,77 @@ function setNestedByDot(obj, dotPath, value) {
6761
7534
  if (lastKey !== void 0) current[lastKey] = value;
6762
7535
  }
6763
7536
  //#endregion
6764
- //#region ../../node_modules/.pnpm/hearback-core@0.1.1/node_modules/hearback-core/dist/schema.js
6765
- const FeedbackType = z.enum(["bug", "feature"]);
6766
- const BrowserContext = z.object({
6767
- url: z.string().optional(),
6768
- userAgent: z.string().optional(),
6769
- browser: z.string().optional(),
6770
- os: z.string().optional(),
6771
- viewport: z.string().optional()
6772
- });
6773
- const ConsoleError = z.object({
6774
- message: z.string(),
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
7597
+ //#region ../../node_modules/.pnpm/hearback-core@0.1.1/node_modules/hearback-core/dist/schema.js
7598
+ const FeedbackType = z.enum(["bug", "feature"]);
7599
+ const BrowserContext = z.object({
7600
+ url: z.string().optional(),
7601
+ userAgent: z.string().optional(),
7602
+ browser: z.string().optional(),
7603
+ os: z.string().optional(),
7604
+ viewport: z.string().optional()
7605
+ });
7606
+ const ConsoleError = z.object({
7607
+ message: z.string(),
6775
7608
  stack: z.string().optional(),
6776
7609
  timestamp: z.string().optional()
6777
7610
  });
@@ -7518,7 +8351,7 @@ function createFeedbackHandler(config) {
7518
8351
  return { handle };
7519
8352
  }
7520
8353
  //#endregion
7521
- //#region ../server/dist/app-BvSSa9Ak.mjs
8354
+ //#region ../server/dist/app-D-aIvdiQ.mjs
7522
8355
  var __defProp = Object.defineProperty;
7523
8356
  var __exportAll = (all, no_symbols) => {
7524
8357
  let target = {};
@@ -7572,6 +8405,7 @@ const agents = pgTable("agents", {
7572
8405
  metadata: jsonb("metadata").$type().notNull().default({}),
7573
8406
  managerId: text("manager_id").notNull(),
7574
8407
  clientId: text("client_id").references(() => clients.id, { onDelete: "restrict" }),
8408
+ runtimeProvider: text("runtime_provider").notNull().default("claude-code"),
7575
8409
  createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
7576
8410
  updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
7577
8411
  }, (table) => [
@@ -8241,7 +9075,7 @@ async function deleteAdapterConfig(db, id) {
8241
9075
  const [row] = await db.delete(adapterConfigs).where(eq(adapterConfigs.id, id)).returning();
8242
9076
  if (!row) throw new NotFoundError(`Adapter config "${id}" not found`);
8243
9077
  }
8244
- const log$4 = createLogger$1("AdminAdapters");
9078
+ const log$5 = createLogger$1("AdminAdapters");
8245
9079
  function parseId(raw) {
8246
9080
  const id = Number(raw);
8247
9081
  if (!Number.isInteger(id) || id <= 0) throw new BadRequestError(`Invalid adapter ID: "${raw}"`);
@@ -8261,7 +9095,7 @@ async function adminAdapterRoutes(app) {
8261
9095
  const scope = memberScope(request);
8262
9096
  await assertCanManage(app.db, scope, body.agentId);
8263
9097
  const config = await createAdapterConfig(app.db, body, app.config.secrets.encryptionKey);
8264
- 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"));
8265
9099
  app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
8266
9100
  return reply.status(201).send({
8267
9101
  ...config,
@@ -8285,7 +9119,7 @@ async function adminAdapterRoutes(app) {
8285
9119
  const existing = await getAdapterConfig(app.db, id);
8286
9120
  await assertCanManage(app.db, scope, existing.agentId);
8287
9121
  const config = await updateAdapterConfig(app.db, id, body, app.config.secrets.encryptionKey);
8288
- 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"));
8289
9123
  app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
8290
9124
  return {
8291
9125
  ...config,
@@ -8299,7 +9133,7 @@ async function adminAdapterRoutes(app) {
8299
9133
  const existing = await getAdapterConfig(app.db, id);
8300
9134
  await assertCanManage(app.db, scope, existing.agentId);
8301
9135
  await deleteAdapterConfig(app.db, id);
8302
- 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"));
8303
9137
  app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
8304
9138
  return reply.status(204).send();
8305
9139
  });
@@ -8400,6 +9234,38 @@ const members = pgTable("members", {
8400
9234
  * real account.
8401
9235
  */
8402
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
+ }
8403
9269
  /** Default visibility per agent type. */
8404
9270
  function defaultVisibility(type) {
8405
9271
  switch (type) {
@@ -8420,6 +9286,32 @@ function defaultVisibility(type) {
8420
9286
  * - When a non-human agent IS created with a `clientId`, the pinned client
8421
9287
  * must already be owned by the manager's user (Rule R-RUN).
8422
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
+ }
8423
9315
  async function resolveAgentClient(db, data) {
8424
9316
  if (data.type === "human") {
8425
9317
  if (data.clientId) throw new BadRequestError("Human agents cannot be pinned to a client");
@@ -8452,9 +9344,10 @@ async function resolveFallbackManagerId(db, orgId) {
8452
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\`).`);
8453
9345
  return row.id;
8454
9346
  }
8455
- async function createAgent(db, data) {
9347
+ async function createAgent(db, data, options = {}) {
8456
9348
  const uuid = uuidv7();
8457
9349
  const name = data.name ?? null;
9350
+ const runtimeProvider = data.runtimeProvider ?? "claude-code";
8458
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`);
8459
9352
  if (name && isReservedAgentName$1(name)) throw new BadRequestError(`Agent name "${name}" is reserved — pick a different one.`);
8460
9353
  const inboxId = `inbox_${uuid}`;
@@ -8480,6 +9373,7 @@ async function createAgent(db, data) {
8480
9373
  managerId,
8481
9374
  type: data.type
8482
9375
  });
9376
+ await ensureClientSupportsRuntimeProvider(db, clientId, runtimeProvider, { force: options.force });
8483
9377
  const [org] = await db.select({ maxAgents: organizations.maxAgents }).from(organizations).where(eq(organizations.id, orgId)).limit(1);
8484
9378
  if (org && org.maxAgents > 0) {
8485
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.`);
@@ -8498,13 +9392,14 @@ async function createAgent(db, data) {
8498
9392
  visibility: data.visibility ?? defaultVisibility(data.type),
8499
9393
  metadata: data.metadata ?? {},
8500
9394
  managerId,
8501
- clientId
9395
+ clientId,
9396
+ runtimeProvider
8502
9397
  }).returning();
8503
9398
  if (!agent) throw new Error("Unexpected: INSERT RETURNING produced no row");
8504
9399
  await db.insert(agentConfigs).values({
8505
9400
  agentId: agent.uuid,
8506
9401
  version: 1,
8507
- payload: DEFAULT_AGENT_RUNTIME_CONFIG_PAYLOAD,
9402
+ payload: defaultRuntimeConfigPayload(runtimeProvider),
8508
9403
  updatedBy: "system"
8509
9404
  }).onConflictDoNothing();
8510
9405
  return agent;
@@ -8558,6 +9453,7 @@ async function listAgentsForAdmin(db, scope, limit, cursor) {
8558
9453
  metadata: agents.metadata,
8559
9454
  managerId: agents.managerId,
8560
9455
  clientId: agents.clientId,
9456
+ runtimeProvider: agents.runtimeProvider,
8561
9457
  createdAt: agents.createdAt,
8562
9458
  updatedAt: agents.updatedAt,
8563
9459
  presenceStatus: agentPresence.status,
@@ -8595,6 +9491,7 @@ async function listAgentsForMember(db, scope, limit, cursor, type) {
8595
9491
  metadata: agents.metadata,
8596
9492
  managerId: agents.managerId,
8597
9493
  clientId: agents.clientId,
9494
+ runtimeProvider: agents.runtimeProvider,
8598
9495
  createdAt: agents.createdAt,
8599
9496
  updatedAt: agents.updatedAt,
8600
9497
  presenceStatus: agentPresence.status,
@@ -8614,7 +9511,7 @@ async function updateAgent(db, uuid, data) {
8614
9511
  const agent = await getAgent(db, uuid);
8615
9512
  if (data.clientId !== void 0) {
8616
9513
  if (data.clientId === null) throw new BadRequestError("clientId cannot be cleared — once bound, an agent stays bound to its client");
8617
- 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.");
8618
9515
  }
8619
9516
  const updates = { updatedAt: /* @__PURE__ */ new Date() };
8620
9517
  if (data.type !== void 0) updates.type = data.type;
@@ -8645,6 +9542,39 @@ async function updateAgent(db, uuid, data) {
8645
9542
  return updated;
8646
9543
  }
8647
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
+ /**
8648
9578
  * Reactivate a suspended agent.
8649
9579
  */
8650
9580
  async function reactivateAgent(db, uuid) {
@@ -9224,10 +10154,45 @@ async function listActiveAgentsPinnedToClient(db, clientId) {
9224
10154
  uuid: agents.uuid,
9225
10155
  name: agents.name,
9226
10156
  displayName: agents.displayName,
9227
- type: agents.type
10157
+ type: agents.type,
10158
+ runtimeProvider: agents.runtimeProvider
9228
10159
  }).from(agents).where(and(eq(agents.clientId, clientId), ne(agents.status, "deleted")));
9229
10160
  }
9230
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
+ /**
9231
10196
  * Scope-aware client listing.
9232
10197
  *
9233
10198
  * - member: rows where `user_id = scope.userId` AND `organization_id = scope.organizationId`
@@ -9440,6 +10405,126 @@ const inboxEntries = pgTable("inbox_entries", {
9440
10405
  index("idx_inbox_pending_notify").on(table.inboxId, table.createdAt).where(sql`status = 'pending' AND notify = true`),
9441
10406
  index("idx_inbox_chat_silent").on(table.inboxId, table.chatId, table.notify, table.status)
9442
10407
  ]);
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");
9443
10528
  async function sendMessage(db, chatId, senderId, data, options = {}) {
9444
10529
  return withSpan("inbox.enqueue", messageAttrs({
9445
10530
  chatId,
@@ -9448,17 +10533,24 @@ async function sendMessage(db, chatId, senderId, data, options = {}) {
9448
10533
  }), () => sendMessageInner(db, chatId, senderId, data, options));
9449
10534
  }
9450
10535
  async function sendMessageInner(db, chatId, senderId, data, options) {
9451
- return db.transaction(async (tx) => {
9452
- const [participants, [chatRow]] = await Promise.all([tx.select({
9453
- agentId: chatParticipants.agentId,
9454
- inboxId: agents.inboxId,
9455
- mode: chatParticipants.mode,
9456
- name: agents.name
9457
- }).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)]);
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
+ ]);
9458
10550
  const chatType = chatRow?.type ?? null;
10551
+ if (!senderRow) throw new NotFoundError(`Sender agent "${senderId}" not found`);
9459
10552
  if (data.replyToInbox !== void 0 && data.replyToInbox !== null) {
9460
- const [senderRow] = await tx.select({ inboxId: agents.inboxId }).from(agents).where(eq(agents.uuid, senderId)).limit(1);
9461
- if (!senderRow || senderRow.inboxId !== data.replyToInbox) throw new BadRequestError("replyToInbox must reference the sender's own inbox");
10553
+ if (senderRow.inboxId !== data.replyToInbox) throw new BadRequestError("replyToInbox must reference the sender's own inbox");
9462
10554
  }
9463
10555
  const incomingMeta = data.metadata ?? {};
9464
10556
  const explicitMentionsRaw = incomingMeta.mentions;
@@ -9503,14 +10595,20 @@ async function sendMessageInner(db, chatId, senderId, data, options) {
9503
10595
  source: data.source ?? null
9504
10596
  }).returning();
9505
10597
  const mentionSet = new Set(mergedMentions);
9506
- 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,
9507
10600
  inboxId: p.inboxId,
9508
- messageId,
9509
- chatId,
9510
10601
  notify: p.mode !== "mention_only" || mentionSet.has(p.agentId)
9511
10602
  }));
9512
- if (entries.length > 0) await tx.insert(inboxEntries).values(entries);
9513
- 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);
9514
10612
  if (data.inReplyTo) {
9515
10613
  const [original] = await tx.select({
9516
10614
  replyToInbox: messages.replyToInbox,
@@ -9529,9 +10627,24 @@ async function sendMessageInner(db, chatId, senderId, data, options) {
9529
10627
  if (!msg) throw new Error("Unexpected: INSERT RETURNING produced no row");
9530
10628
  return {
9531
10629
  message: msg,
9532
- recipients
10630
+ recipients,
10631
+ recipientAgentIds,
10632
+ organizationId: senderRow.organizationId
9533
10633
  };
9534
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
+ };
9535
10648
  }
9536
10649
  async function sendToAgent(db, senderUuid, targetName, data) {
9537
10650
  const [sender] = await db.select({
@@ -9758,7 +10871,8 @@ async function adminAgentRoutes(app) {
9758
10871
  agentId: agent.uuid,
9759
10872
  name: agent.name,
9760
10873
  displayName: agent.displayName,
9761
- agentType: agent.type
10874
+ agentType: agent.type,
10875
+ runtimeProvider: agent.runtimeProvider
9762
10876
  });
9763
10877
  if (!parsed.success) {
9764
10878
  app.log.warn({
@@ -9861,6 +10975,23 @@ async function adminAgentRoutes(app) {
9861
10975
  updatedAt: agent.updatedAt.toISOString()
9862
10976
  };
9863
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
+ });
9864
10995
  app.get("/:uuid", async (request) => {
9865
10996
  const scope = memberScope(request);
9866
10997
  await assertAgentVisible(app.db, scope, request.params.uuid);
@@ -10315,107 +11446,6 @@ async function adminChatRoutes(app) {
10315
11446
  });
10316
11447
  });
10317
11448
  }
10318
- /** Per-session state snapshot. One row per (agent, chat) pair, upserted on each session:state message. */
10319
- const agentChatSessions = pgTable("agent_chat_sessions", {
10320
- agentId: text("agent_id").notNull().references(() => agents.uuid, { onDelete: "cascade" }),
10321
- chatId: text("chat_id").notNull().references(() => chats.id, { onDelete: "cascade" }),
10322
- state: text("state").notNull(),
10323
- updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
10324
- }, (table) => [primaryKey({ columns: [table.agentId, table.chatId] })]);
10325
- /**
10326
- * Upsert session state + refresh presence aggregates + NOTIFY.
10327
- *
10328
- * `agent_chat_sessions.(agent_id, chat_id)` is a single-row "current session
10329
- * state" cache, not a session history log. A new runtime session starting on
10330
- * the same (agent, chat) pair MUST overwrite whatever ended before — including
10331
- * an `evicted` row left by a previous terminate. The previous "revival
10332
- * defense" conflated two concerns: "this runtime session ended" (which is
10333
- * what `evicted` actually means) and "this chat is permanently archived for
10334
- * this agent" (a chat-level decision that should live on `chats`, not here).
10335
- * See proposals/hub-agent-messaging-reply-and-mentions §M2-session-lifecycle.
10336
- */
10337
- async function upsertSessionState(db, agentId, chatId, state, organizationId, notifier) {
10338
- const now = /* @__PURE__ */ new Date();
10339
- let wrote = false;
10340
- await db.transaction(async (tx) => {
10341
- await tx.insert(agentChatSessions).values({
10342
- agentId,
10343
- chatId,
10344
- state,
10345
- updatedAt: now
10346
- }).onConflictDoUpdate({
10347
- target: [agentChatSessions.agentId, agentChatSessions.chatId],
10348
- set: {
10349
- state,
10350
- updatedAt: now
10351
- }
10352
- });
10353
- const [counts] = await tx.select({
10354
- active: sql`count(*) FILTER (WHERE ${agentChatSessions.state} = 'active')::int`,
10355
- total: sql`count(*) FILTER (WHERE ${agentChatSessions.state} != 'evicted')::int`
10356
- }).from(agentChatSessions).where(eq(agentChatSessions.agentId, agentId));
10357
- const activeSessions = counts?.active ?? 0;
10358
- const totalSessions = counts?.total ?? 0;
10359
- await tx.update(agentPresence).set({
10360
- activeSessions,
10361
- totalSessions,
10362
- lastSeenAt: now
10363
- }).where(eq(agentPresence.agentId, agentId));
10364
- wrote = true;
10365
- });
10366
- if (wrote && notifier) notifier.notifySessionStateChange(agentId, chatId, state, organizationId).catch(() => {});
10367
- }
10368
- async function resetActivity(db, agentId) {
10369
- const now = /* @__PURE__ */ new Date();
10370
- await db.update(agentPresence).set({
10371
- runtimeState: "idle",
10372
- runtimeUpdatedAt: now
10373
- }).where(eq(agentPresence.agentId, agentId));
10374
- }
10375
- async function getActivityOverview(db) {
10376
- const [agentCounts] = await db.select({
10377
- total: sql`count(*)::int`,
10378
- running: sql`count(*) FILTER (WHERE ${agentPresence.runtimeState} IS NOT NULL)::int`,
10379
- idle: sql`count(*) FILTER (WHERE ${agentPresence.runtimeState} = 'idle')::int`,
10380
- working: sql`count(*) FILTER (WHERE ${agentPresence.runtimeState} = 'working')::int`,
10381
- blocked: sql`count(*) FILTER (WHERE ${agentPresence.runtimeState} = 'blocked')::int`,
10382
- error: sql`count(*) FILTER (WHERE ${agentPresence.runtimeState} = 'error')::int`
10383
- }).from(agentPresence);
10384
- const [clientCounts] = await db.select({ count: sql`count(*)::int` }).from(clients).where(eq(clients.status, "connected"));
10385
- return {
10386
- total: agentCounts?.total ?? 0,
10387
- running: agentCounts?.running ?? 0,
10388
- byState: {
10389
- idle: agentCounts?.idle ?? 0,
10390
- working: agentCounts?.working ?? 0,
10391
- blocked: agentCounts?.blocked ?? 0,
10392
- error: agentCounts?.error ?? 0
10393
- },
10394
- clients: clientCounts?.count ?? 0
10395
- };
10396
- }
10397
- /**
10398
- * List agents with active runtime state.
10399
- * When scope is provided, filters to agents visible to the member.
10400
- */
10401
- async function listAgentsWithRuntime(db, scope) {
10402
- if (!scope) return db.select().from(agentPresence).where(isNotNull(agentPresence.runtimeState));
10403
- return db.select({
10404
- agentId: agentPresence.agentId,
10405
- status: agentPresence.status,
10406
- instanceId: agentPresence.instanceId,
10407
- connectedAt: agentPresence.connectedAt,
10408
- lastSeenAt: agentPresence.lastSeenAt,
10409
- clientId: agentPresence.clientId,
10410
- runtimeType: agentPresence.runtimeType,
10411
- runtimeVersion: agentPresence.runtimeVersion,
10412
- runtimeState: agentPresence.runtimeState,
10413
- activeSessions: agentPresence.activeSessions,
10414
- totalSessions: agentPresence.totalSessions,
10415
- runtimeUpdatedAt: agentPresence.runtimeUpdatedAt,
10416
- type: agents.type
10417
- }).from(agentPresence).innerJoin(agents, eq(agentPresence.agentId, agents.uuid)).where(and(isNotNull(agentPresence.runtimeState), agentVisibilityCondition(scope)));
10418
- }
10419
11449
  /** Serialize a Date to ISO string, or null. */
10420
11450
  function serializeDate(d) {
10421
11451
  return d ? d.toISOString() : null;
@@ -10439,11 +11469,27 @@ async function adminClientRoutes(app) {
10439
11469
  lastSeenAt: c.lastSeenAt.toISOString()
10440
11470
  }));
10441
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
+ });
10442
11486
  app.get("/:clientId", async (request) => {
10443
11487
  const scope = memberScope(request);
10444
11488
  await assertClientOwner(app.db, request.params.clientId, scope);
10445
11489
  const client = await getClient(app.db, request.params.clientId);
10446
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 : {};
10447
11493
  return {
10448
11494
  id: client.id,
10449
11495
  userId: client.userId,
@@ -10452,7 +11498,8 @@ async function adminClientRoutes(app) {
10452
11498
  hostname: client.hostname,
10453
11499
  os: client.os,
10454
11500
  connectedAt: serializeDate(client.connectedAt),
10455
- lastSeenAt: client.lastSeenAt.toISOString()
11501
+ lastSeenAt: client.lastSeenAt.toISOString(),
11502
+ capabilities
10456
11503
  };
10457
11504
  });
10458
11505
  app.post("/:clientId/disconnect", async (request) => {
@@ -12751,7 +13798,8 @@ function clientWsRoutes(notifier, instanceId) {
12751
13798
  agentId: agent.uuid,
12752
13799
  name: agent.name,
12753
13800
  displayName: agent.displayName,
12754
- agentType: agent.type
13801
+ agentType: agent.type,
13802
+ runtimeProvider: agent.runtimeProvider
12755
13803
  });
12756
13804
  if (!parsed.success) {
12757
13805
  app.log.warn({
@@ -12787,6 +13835,7 @@ function clientWsRoutes(notifier, instanceId) {
12787
13835
  inboxId: agents.inboxId,
12788
13836
  status: agents.status,
12789
13837
  clientId: agents.clientId,
13838
+ runtimeProvider: agents.runtimeProvider,
12790
13839
  clientUserId: clients.userId,
12791
13840
  managerUserId: members.userId
12792
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);
@@ -12821,6 +13870,10 @@ function clientWsRoutes(notifier, instanceId) {
12821
13870
  sendRejected(socket, ref, AGENT_BIND_REJECT_REASONS.NOT_OWNED);
12822
13871
  return;
12823
13872
  }
13873
+ if (bindRequest.runtimeType !== agent.runtimeProvider) {
13874
+ sendRejected(socket, ref, AGENT_BIND_REJECT_REASONS.RUNTIME_PROVIDER_MISMATCH);
13875
+ return;
13876
+ }
12824
13877
  await bindAgent(app.db, agent.id, {
12825
13878
  clientId,
12826
13879
  instanceId,
@@ -13169,7 +14222,8 @@ const authIdentities = pgTable("auth_identities", {
13169
14222
  }, (table) => [
13170
14223
  unique("uq_auth_identities_provider_identifier").on(table.provider, table.identifier),
13171
14224
  index("idx_auth_identities_user").on(table.userId),
13172
- index("idx_auth_identities_email").on(table.email)
14225
+ index("idx_auth_identities_email").on(table.email),
14226
+ uniqueIndex("uq_auth_identities_user_github").on(table.userId).where(sql`provider = 'github'`)
13173
14227
  ]);
13174
14228
  /**
13175
14229
  * Find or create the user backing a GitHub OAuth identity. Idempotent —
@@ -13640,9 +14694,7 @@ async function githubOauthRoutes(app) {
13640
14694
  return completeOauthFlow(app, request, reply, profile, next);
13641
14695
  });
13642
14696
  app.get("/dev-callback", async (request, reply) => {
13643
- const isProd = process.env.NODE_ENV === "production";
13644
- const devEnabled = oauthCfg?.devCallbackEnabled === true;
13645
- if (isProd || !devEnabled) return reply.status(404).send({ error: "Not found" });
14697
+ if (process.env.NODE_ENV === "production") return reply.status(404).send({ error: "Not found" });
13646
14698
  const params = githubDevCallbackQuerySchema.parse(request.query);
13647
14699
  const next = safeRedirectPath(params.next ?? null);
13648
14700
  return completeOauthFlow(app, request, reply, {
@@ -14046,7 +15098,7 @@ async function inferWizardStep(app, m) {
14046
15098
  * landing page.
14047
15099
  */
14048
15100
  async function publicInvitePreviewRoute(app) {
14049
- const { previewInvitation } = await import("./invitation-BTlGMy0o-dIoR8JRj.mjs");
15101
+ const { previewInvitation } = await import("./invitation-BTlGMy0o-Coj07kYi.mjs");
14050
15102
  app.get("/:token/preview", async (request, reply) => {
14051
15103
  if (!request.params.token) throw new UnauthorizedError("Token required");
14052
15104
  const preview = await previewInvitation(app.db, request.params.token);
@@ -14076,7 +15128,7 @@ async function adminInvitationRoutes(app) {
14076
15128
  const m = requireMember(request);
14077
15129
  if (m.role !== "admin") throw new ForbiddenError("Admin role required");
14078
15130
  if (request.params.id !== m.organizationId) throw new ForbiddenError("Cannot rotate invitations for another organization");
14079
- const { rotateInvitation } = await import("./invitation-BTlGMy0o-dIoR8JRj.mjs");
15131
+ const { rotateInvitation } = await import("./invitation-BTlGMy0o-Coj07kYi.mjs");
14080
15132
  const inv = await rotateInvitation(app.db, m.organizationId, m.userId);
14081
15133
  return {
14082
15134
  id: inv.id,
@@ -15443,6 +16495,7 @@ function createConfigService(opts) {
15443
16495
  */
15444
16496
  function applyPatch(current, patch) {
15445
16497
  return {
16498
+ kind: current.kind,
15446
16499
  prompt: patch.prompt ?? current.prompt,
15447
16500
  model: patch.model ?? current.model,
15448
16501
  mcpServers: patch.mcpServers ?? current.mcpServers,
@@ -15468,13 +16521,26 @@ function createConfigService(opts) {
15468
16521
  async function readRow(agentId) {
15469
16522
  const [row] = await db.select().from(agentConfigs).where(eq(agentConfigs.agentId, agentId)).limit(1);
15470
16523
  if (!row) throw new NotFoundError(`Agent config "${agentId}" not found`);
15471
- 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;
15472
16534
  }
15473
16535
  async function commitWrite(agentId, patch, expectedVersion, updatedBy) {
15474
16536
  const current = await readRow(agentId);
15475
16537
  if (current.version !== expectedVersion) throw new ConflictError(`Agent config "${agentId}" version mismatch: expected ${expectedVersion}, got ${current.version}`);
15476
- const merged = applyPatch(current.payload, patch);
15477
- 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);
15478
16544
  const [updated] = await db.update(agentConfigs).set({
15479
16545
  version: sql`${agentConfigs.version} + 1`,
15480
16546
  payload: validated,
@@ -15581,7 +16647,12 @@ function createConfigService(opts) {
15581
16647
  },
15582
16648
  async dryRun(agentId, patch) {
15583
16649
  const row = await readRow(agentId);
15584
- 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);
15585
16656
  const diff = computeDiff(row.payload, next);
15586
16657
  return {
15587
16658
  current: {
@@ -16672,4 +17743,4 @@ function registerSaaSConnectCommand(program) {
16672
17743
  });
16673
17744
  }
16674
17745
  //#endregion
16675
- export { FirstTreeHubSDK as $, checkWebSocket as A, ClientRuntime as B, checkClientConfig as C, checkServerConfig as D, checkNodeVersion as E, resolveCliInvocation as F, resolveReplyToFromEnv as G, rotateClientIdWithBackup as H, uninstallClientService as I, blank as J, fail as K, ensurePostgres as L, getClientServiceStatus as M, installClientService as N, checkServerHealth as O, isServiceSupported as P, ClientOrgMismatchError as Q, isDockerAvailable as R, checkBackgroundService as S, checkDocker as T, createOwner as U, handleClientOrgMismatch as V, hasUser as W, setJsonMode as X, print as Y, status as Z, runHomeMigration as _, declineUpdate as a, runMigrations as b, COMMAND_VERSION as c, promptMissingFields as d, SdkError as et, formatCheckReport as f, saveOnboardState as g, onboardCreate as h, createExecuteUpdate as i, configureClientLoggerForService as it, printResults as j, checkServerReachable as k, isInteractive as l, onboardCheck as m, deriveHubUrlFromToken as n, cleanWorkspaces as nt, promptUpdate as o, loadOnboardState as p, success as q, registerSaaSConnectCommand as r, applyClientLoggerConfig as rt, startServer as s, HubUrlDerivationError as t, SessionRegistry as tt, promptAddAgent as u, createApiNameResolver as v, checkDatabase as w, checkAgentConfigs as x, migrateLocalAgentDirs as y, stopPostgres as z };
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 };