@agent-team-foundation/first-tree-hub 0.10.2 → 0.10.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,8 @@
1
1
  import { m as __toESM } from "./esm-CYu4tXXn.mjs";
2
2
  import { _ as withSpan, a as endWsConnectionSpan, b as require_pino, c as messageAttrs, d as rootLogger$1, g as startWsConnectionSpan, i as currentTraceId, n as applyLoggerConfig, o as getFastifyOtelPlugin, p as setWsConnectionAttrs, r as createLogger$1, t as adapterAttrs, u as observabilityPlugin, v as withWsMessageSpan, y as FIRST_TREE_HUB_ATTR } from "./observability-DPyf745N-BSc8QNcR.mjs";
3
- 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";
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";
3
+ import { C as serverConfigSchema, S as resolveConfigReadonly, _ as loadAgents, b as resetConfig, c as saveCredentials, d as DEFAULT_HOME_DIR$1, f as agentConfigSchema, g as initConfig, i as loadCredentials, l as DEFAULT_CONFIG_DIR, m as collectMissingPrompts, n as ensureFreshAccessToken, o as resolveServerUrl, p as clientConfigSchema, s as saveAgentConfig, u as DEFAULT_DATA_DIR$1, v as migrateLegacyHome, w as setConfigValue, x as resetConfigMeta } from "./bootstrap-jx5nN1qZ.mjs";
4
+ import { $ as refreshTokenSchema, A as createOrgFromMeSchema, B as imageInlineContentSchema, C as clientRegisterSchema, D as createAgentSchema, E as createAdapterMappingSchema, F as dryRunAgentRuntimeConfigSchema, G as isReservedAgentName$1, H as inboxDeliverFrameSchema$1, I as extractMentions, J as loginSchema, K as joinByInvitationSchema, L as githubCallbackQuerySchema, M as createTaskSchema, N as defaultRuntimeConfigPayload, O as createChatSchema, P as delegateFeishuUserSchema, Q as rebindAgentSchema, R as githubDevCallbackQuerySchema, S as clientCapabilitiesSchema$1, St as wsAuthFrameSchema, T as createAdapterConfigSchema, U as inboxPollQuerySchema, V as inboxAckFrameSchema, W as isRedactedEnvValue, X as notificationQuerySchema, Y as messageSourceSchema$1, Z as paginationQuerySchema, _ as adminUpdateTaskSchema, _t as updateClientCapabilitiesSchema, a as AGENT_STATUSES, at as sendToAgentSchema, b as agentRuntimeConfigPayloadSchema$1, bt as updateSystemConfigSchema, ct as sessionEventSchema$1, d as TASK_HEALTH_SIGNALS, dt as switchOrgSchema, et as runtimeStateMessageSchema, f as TASK_STATUSES, ft as taskListQuerySchema, g as adminCreateTaskSchema, gt as updateChatSchema, h as addParticipantSchema, ht as updateAgentSchema, i as AGENT_SOURCES, it as sendMessageSchema, j as createOrganizationSchema, k as createMemberSchema, l as SYSTEM_CONFIG_DEFAULTS, lt as sessionReconcileRequestSchema, m as WS_AUTH_FRAME_TIMEOUT_MS, mt as updateAgentRuntimeConfigSchema, n as AGENT_NAME_REGEX$1, nt as scanMentionTokens, o as AGENT_TYPES, ot as sessionCompletionMessageSchema, p as TASK_TERMINAL_STATUSES, pt as updateAdapterConfigSchema, q as linkTaskChatSchema, r as AGENT_SELECTOR_HEADER$1, rt as selfServiceFeishuBotSchema, s as AGENT_VISIBILITY, st as sessionEventMessageSchema, t as AGENT_BIND_REJECT_REASONS, tt as safeRedirectPath, u as TASK_CREATOR_TYPES, ut as sessionStateMessageSchema, v as agentBindRequestSchema, vt as updateMemberSchema, w as connectTokenExchangeSchema, x as agentTypeSchema$1, xt as updateTaskStatusSchema, y as agentPinnedMessageSchema$1, yt as updateOrganizationSchema, z as githubStartQuerySchema } from "./dist-CbX9mUVH.mjs";
5
+ import { a as ForbiddenError, c as buildInviteUrl, d as getActiveInvitation, f as invitationRedemptions, g as recordRedemption, i as ConflictError, l as ensureActiveInvitation, m as organizations, n as BadRequestError, o as NotFoundError, p as invitations, r as ClientOrgMismatchError$1, s as UnauthorizedError, t as AppError, u as findActiveByToken, v as users, y as uuidv7 } from "./invitation-BljIolbO-DLeHfURd.mjs";
6
6
  import { createRequire } from "node:module";
7
7
  import { ZodError, z } from "zod";
8
8
  import { delimiter, dirname, isAbsolute, join, resolve } from "node:path";
@@ -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(),
@@ -715,12 +754,53 @@ z.object({
715
754
  lastSeenAt: z.string(),
716
755
  metadata: z.record(z.string(), z.unknown()).nullable()
717
756
  });
757
+ /**
758
+ * Optional opt-in flags the client carries on `client:register` to advertise
759
+ * which negotiable wire-protocol features it implements. Distinct from
760
+ * `clientCapabilitiesSchema` (per-runtime-provider availability — different
761
+ * concept). Older clients omit the field; the server treats every unset flag
762
+ * as `false` and falls back to the legacy path. See proposal
763
+ * hub-inbox-ws-data-plane §3.6.
764
+ */
765
+ const clientWireCapabilitiesSchema = z.object({ wsInboxDeliver: z.boolean().default(false) }).partial();
718
766
  z.object({
719
767
  clientId: z.string().min(1).max(100),
720
768
  hostname: z.string().max(100).optional(),
721
769
  os: z.string().max(50).optional(),
722
- sdkVersion: z.string().max(50).optional()
770
+ sdkVersion: z.string().max(50).optional(),
771
+ wireCapabilities: clientWireCapabilitiesSchema.optional()
772
+ });
773
+ const capabilityStateSchema = z.enum([
774
+ "ok",
775
+ "missing",
776
+ "unauthenticated",
777
+ "error"
778
+ ]);
779
+ const capabilityAuthMethodSchema = z.enum([
780
+ "api_key",
781
+ "oauth",
782
+ "auth_json",
783
+ "none"
784
+ ]);
785
+ const capabilityEntrySchema = z.object({
786
+ state: capabilityStateSchema,
787
+ available: z.boolean(),
788
+ authenticated: z.boolean(),
789
+ sdkVersion: z.string().nullable().optional(),
790
+ authMethod: capabilityAuthMethodSchema,
791
+ error: z.string().nullable().optional(),
792
+ detectedAt: z.string()
723
793
  });
794
+ /**
795
+ * Capabilities snapshot keyed by runtime provider name. Recorded as a plain
796
+ * `Record<string, CapabilityEntry>` — every entry is optional (a client may
797
+ * report only the runtimes it actually probed) and the key set evolves
798
+ * naturally as new providers ship without a schema migration. Service-layer
799
+ * lookups (`agents.runtime_provider ∈ keys(capabilities)`) treat the keys
800
+ * as `RuntimeProvider` strings.
801
+ */
802
+ const clientCapabilitiesSchema = z.record(z.string(), capabilityEntrySchema);
803
+ z.object({ capabilities: clientCapabilitiesSchema });
724
804
  z.object({
725
805
  limit: z.coerce.number().int().min(1).max(100).default(20),
726
806
  cursor: z.string().optional()
@@ -898,11 +978,37 @@ z.object({
898
978
  ackedAt: z.string().nullable()
899
979
  }).extend({ message: clientMessageSchema });
900
980
  z.object({ limit: z.coerce.number().int().min(1).max(50).default(10) });
981
+ /**
982
+ * server → client: a single inbox entry pushed over the active WS connection,
983
+ * replacing the legacy `new_message` doorbell + HTTP `/inbox` poll round-trip.
984
+ *
985
+ * `entryId` is the server-side `inbox_entries.id` the client must echo back
986
+ * in `inbox:ack`. `message` is exactly what the legacy poll path returned —
987
+ * `clientMessageSchema` already carries `precedingMessages`, so the client-
988
+ * side dispatch logic is reused verbatim (see proposal
989
+ * hub-inbox-ws-data-plane §3.1).
990
+ *
991
+ * `.passthrough()` so a forward-rolling server may extend the frame without
992
+ * breaking older clients that validate strictly. Older clients drop unknown
993
+ * fields silently.
994
+ */
995
+ const inboxDeliverFrameSchema = z.object({
996
+ type: z.literal("inbox:deliver"),
997
+ entryId: z.number().int().nonnegative(),
998
+ inboxId: z.string().min(1),
999
+ chatId: z.string().nullable(),
1000
+ message: clientMessageSchema
1001
+ }).passthrough();
1002
+ z.object({
1003
+ type: z.literal("inbox:ack"),
1004
+ entryId: z.number().int().nonnegative()
1005
+ });
901
1006
  z.object({
902
1007
  organizationId: z.string(),
903
1008
  organizationName: z.string(),
904
1009
  organizationDisplayName: z.string(),
905
- role: z.string()
1010
+ role: z.string(),
1011
+ expiresAt: z.string().nullable()
906
1012
  });
907
1013
  z.object({
908
1014
  id: z.string(),
@@ -1273,6 +1379,13 @@ z.object({
1273
1379
  token: z.string().min(1)
1274
1380
  });
1275
1381
  /**
1382
+ * Negotiable wire-protocol features the server advertises in its `welcome`
1383
+ * frame. Older clients drop the `capabilities` field silently because the
1384
+ * frame is `.passthrough()`. New clients gate optional code paths on it —
1385
+ * absent ⇒ feature off, never assumed.
1386
+ */
1387
+ const serverCapabilitiesSchema = z.object({ wsInboxDeliver: z.boolean().default(false) }).partial();
1388
+ /**
1276
1389
  * Advisory frame sent server → client immediately after `auth:ok`. It carries
1277
1390
  * the Command-package version the server was bundled with, so the client can
1278
1391
  * detect version drift on startup and on each reconnect. `.passthrough()` so
@@ -1282,7 +1395,8 @@ z.object({
1282
1395
  const serverWelcomeFrameSchema = z.object({
1283
1396
  type: z.literal("server:welcome"),
1284
1397
  serverCommandVersion: z.string().min(1),
1285
- serverTimeMs: z.number().int().nonnegative()
1398
+ serverTimeMs: z.number().int().nonnegative(),
1399
+ capabilities: serverCapabilitiesSchema.optional()
1286
1400
  }).passthrough();
1287
1401
  /** Declare a config field with a Zod schema and optional metadata. */
1288
1402
  function field(schema, options) {
@@ -1410,8 +1524,7 @@ defineConfig({
1410
1524
  clientSecret: field(z.string(), {
1411
1525
  env: "FIRST_TREE_HUB_GITHUB_OAUTH_CLIENT_SECRET",
1412
1526
  secret: true
1413
- }),
1414
- devCallbackEnabled: field(z.boolean().default(false), { env: "FIRST_TREE_HUB_GITHUB_OAUTH_DEV_CALLBACK" })
1527
+ })
1415
1528
  }) }),
1416
1529
  cors: optional({ origin: field(z.string(), { env: "FIRST_TREE_HUB_CORS_ORIGIN" }) }),
1417
1530
  rateLimit: optional({
@@ -1419,6 +1532,7 @@ defineConfig({
1419
1532
  loginMax: field(z.number().default(5), { env: "FIRST_TREE_HUB_RATE_LIMIT_LOGIN_MAX" }),
1420
1533
  webhookMax: field(z.number().default(60), { env: "FIRST_TREE_HUB_RATE_LIMIT_WEBHOOK_MAX" })
1421
1534
  }),
1535
+ inbox: optional({ maxInFlightPerAgent: field(z.number().int().min(1).max(1024).default(32), { env: "FIRST_TREE_HUB_INBOX_MAX_IN_FLIGHT_PER_AGENT" }) }),
1422
1536
  kael: optional({
1423
1537
  endpoint: field(z.string(), { env: "KAEL_ENDPOINT" }),
1424
1538
  apiKey: field(z.string(), {
@@ -1536,6 +1650,25 @@ var FirstTreeHubSDK = class {
1536
1650
  async fetchAgentConfig() {
1537
1651
  return this.requestJson("/api/v1/agent/config");
1538
1652
  }
1653
+ /**
1654
+ * Member-scoped: report this client's runtime-provider capabilities. The
1655
+ * server stores them under `clients.metadata.capabilities` after checking
1656
+ * that the connected member owns the client.
1657
+ */
1658
+ async updateCapabilities(clientId, capabilities) {
1659
+ await this.requestVoid(`/api/v1/clients/${encodeURIComponent(clientId)}/capabilities`, {
1660
+ method: "PATCH",
1661
+ body: JSON.stringify({ capabilities })
1662
+ });
1663
+ }
1664
+ /**
1665
+ * Member-scoped: every agent pinned to a client owned by the calling user.
1666
+ * Used by client startup to reconcile the local `agent.yaml::runtime` with
1667
+ * the authoritative `agents.runtime_provider` before spawning handlers.
1668
+ */
1669
+ async listMyAgents() {
1670
+ return this.requestJson("/api/v1/clients/me/agents");
1671
+ }
1539
1672
  async isHubReachable(timeoutMs = 3e3) {
1540
1673
  try {
1541
1674
  const url = `${this._baseUrl}/api/v1/health`;
@@ -1643,6 +1776,17 @@ const RECONNECT_MAX_MS = 3e4;
1643
1776
  const WS_CONNECT_TIMEOUT_MS = 1e4;
1644
1777
  const HEARTBEAT_INTERVAL_MS = 3e4;
1645
1778
  /**
1779
+ * Client-side opt-in for the WS inbox data plane. Gates BOTH the
1780
+ * `wireCapabilities.wsInboxDeliver` flag we declare on `client:register`
1781
+ * AND how we interpret the server's welcome capability — without this AND,
1782
+ * a future client kill-switch could land in a half-state where we tell the
1783
+ * server "no thanks" but still treat welcome's `wsInboxDeliver:true` as
1784
+ * authoritative and stop the 5s HTTP poll, leaving messages stuck if a
1785
+ * NOTIFY ever drops. Hard-coded `true` for now; flip to a config knob if
1786
+ * you need a runtime kill-switch.
1787
+ */
1788
+ const WS_INBOX_DELIVER_OPT_IN = true;
1789
+ /**
1646
1790
  * Unified-user-token C5: reconnect PROACTIVELY this many ms before the JWT's
1647
1791
  * `exp` claim so the client rotates to a fresh JWT without ever hitting the
1648
1792
  * server-side `auth:expired` push. The provider's next `getAccessToken()` call
@@ -1694,6 +1838,15 @@ var ClientConnection = class extends EventEmitter {
1694
1838
  /** Count of `server:welcome` frames received; drives `isReconnect` flag. */
1695
1839
  welcomeFramesReceived = 0;
1696
1840
  /**
1841
+ * Whether the most recent `server:welcome` frame advertised
1842
+ * `capabilities.wsInboxDeliver`. The runtime (AgentSlot) reads this
1843
+ * (via {@link supportsWsInboxDeliver}) to decide whether to keep the
1844
+ * legacy 5s HTTP poll or rely entirely on `inbox:deliver` push frames.
1845
+ * Re-evaluated on every reconnect — the welcome frame is the source of
1846
+ * truth, never assumed sticky across connections.
1847
+ */
1848
+ wsInboxDeliverActive = false;
1849
+ /**
1697
1850
  * Last handshake error, stashed for the `close` handler to surface a typed
1698
1851
  * reason (e.g. {@link ClientOrgMismatchError}) instead of a generic
1699
1852
  * "closed before ready" when `connect()` is pending.
@@ -1729,6 +1882,30 @@ var ClientConnection = class extends EventEmitter {
1729
1882
  get agents() {
1730
1883
  return this.boundAgents;
1731
1884
  }
1885
+ /**
1886
+ * True when the current connection's `server:welcome` advertised
1887
+ * `capabilities.wsInboxDeliver` — meaning the server will push
1888
+ * `inbox:deliver` frames and accept `inbox:ack` frames over this WS.
1889
+ * Resets to false on every reconnect until the new welcome arrives.
1890
+ */
1891
+ get supportsWsInboxDeliver() {
1892
+ return this.wsInboxDeliverActive;
1893
+ }
1894
+ /**
1895
+ * Ack a delivered inbox entry over the WS data plane. Replaces the legacy
1896
+ * `sdk.ack()` HTTP call when the connection has negotiated
1897
+ * `wsInboxDeliver`. Safe to call when the WS is closed — the frame is
1898
+ * dropped silently and the entry will time out and re-deliver on
1899
+ * reconnect, mirroring how the legacy timeout reaper handles HTTP
1900
+ * ack-loss.
1901
+ */
1902
+ sendInboxAck(entryId) {
1903
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
1904
+ this.ws.send(JSON.stringify({
1905
+ type: "inbox:ack",
1906
+ entryId
1907
+ }));
1908
+ }
1732
1909
  async connect() {
1733
1910
  this.closing = false;
1734
1911
  await this.openWebSocket();
@@ -1880,6 +2057,7 @@ var ClientConnection = class extends EventEmitter {
1880
2057
  this.clearAuthRefreshTimer();
1881
2058
  const wasRegistered = this.registered;
1882
2059
  this.registered = false;
2060
+ this.wsInboxDeliverActive = false;
1883
2061
  this.rejectAllPendingBinds("WebSocket closed");
1884
2062
  if (!settled) {
1885
2063
  this.wsLogger.warn({ code }, "closed before ready");
@@ -1911,7 +2089,8 @@ var ClientConnection = class extends EventEmitter {
1911
2089
  clientId: this.clientId,
1912
2090
  hostname: hostname(),
1913
2091
  os: platform(),
1914
- sdkVersion: this.sdkVersion
2092
+ sdkVersion: this.sdkVersion,
2093
+ wireCapabilities: { wsInboxDeliver: WS_INBOX_DELIVER_OPT_IN }
1915
2094
  }));
1916
2095
  return;
1917
2096
  }
@@ -1921,6 +2100,7 @@ var ClientConnection = class extends EventEmitter {
1921
2100
  this.wsLogger.warn({ issues: parsed.error.issues.map((i) => i.message) }, "ignoring malformed server:welcome frame");
1922
2101
  return;
1923
2102
  }
2103
+ this.wsInboxDeliverActive = parsed.data.capabilities?.wsInboxDeliver === true && WS_INBOX_DELIVER_OPT_IN;
1924
2104
  const isReconnect = this.welcomeFramesReceived > 0;
1925
2105
  this.welcomeFramesReceived++;
1926
2106
  this.emit("server:welcome", {
@@ -2065,6 +2245,25 @@ var ClientConnection = class extends EventEmitter {
2065
2245
  else this.emit("agent:message", inboxId, msg);
2066
2246
  return;
2067
2247
  }
2248
+ if (type === "inbox:deliver") {
2249
+ const parsed = inboxDeliverFrameSchema.safeParse(msg);
2250
+ if (!parsed.success) {
2251
+ this.wsLogger.warn({
2252
+ issues: parsed.error.issues.map((i) => ({
2253
+ path: i.path.join("."),
2254
+ code: i.code,
2255
+ message: i.message
2256
+ })),
2257
+ frameKeys: Object.keys(msg),
2258
+ messageKeys: msg.message && typeof msg.message === "object" ? Object.keys(msg.message) : null
2259
+ }, "malformed inbox:deliver frame — dropping");
2260
+ return;
2261
+ }
2262
+ const emit = () => this.emit("inbox:deliver", parsed.data.inboxId, parsed.data);
2263
+ if (this.pendingImageWrites.size > 0) Promise.all([...this.pendingImageWrites]).finally(emit);
2264
+ else emit();
2265
+ return;
2266
+ }
2068
2267
  if (type === "error") {
2069
2268
  const errorMsg = msg.message;
2070
2269
  const ref = msg.ref;
@@ -2175,13 +2374,23 @@ function getHandlerFactory(type) {
2175
2374
  }
2176
2375
  join(DEFAULT_DATA_DIR, "context-tree");
2177
2376
  /**
2178
- * Bootstrap a workspace with .agent/ directory files.
2377
+ * Marker file written into every workspace so the Codex CLI's project-root
2378
+ * detection (configured via `project_root_markers: ["first-tree-workspace"]`)
2379
+ * stops at the workspace boundary instead of walking up the filesystem and
2380
+ * loading an unintended `AGENTS.md` from the operator's home or repo root.
2381
+ */
2382
+ const FIRST_TREE_WORKSPACE_MARKER = ".first-tree-workspace";
2383
+ /**
2384
+ * Bootstrap a workspace with `.agent/` directory files plus the workspace
2385
+ * root marker (and an optional provider-specific briefing).
2179
2386
  *
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().
2387
+ * Writes identity.json, context/agent-instructions.md (if context tree
2388
+ * available), tools.md, the `.first-tree-workspace` marker, and for
2389
+ * Codex — `AGENTS.md`. Idempotent: safe to call on every handler start()
2390
+ * and on resume().
2182
2391
  */
2183
2392
  function bootstrapWorkspace(options) {
2184
- const { workspacePath, identity, contextTreePath, serverUrl, chatId } = options;
2393
+ const { workspacePath, identity, contextTreePath, serverUrl, chatId, briefing } = options;
2185
2394
  const agentDir = join(workspacePath, ".agent");
2186
2395
  const contextDir = join(agentDir, "context");
2187
2396
  if (existsSync(contextDir)) rmSync(contextDir, {
@@ -2207,6 +2416,8 @@ function bootstrapWorkspace(options) {
2207
2416
  if (existsSync(rootNodePath)) copyFileSync(rootNodePath, join(contextDir, "domain-map.md"));
2208
2417
  }
2209
2418
  writeFileSync(join(agentDir, "tools.md"), generateToolsDoc(), "utf-8");
2419
+ writeFileSync(join(workspacePath, FIRST_TREE_WORKSPACE_MARKER), "", "utf-8");
2420
+ if (briefing?.format === "agents-md") writeFileSync(join(workspacePath, "AGENTS.md"), briefing.content, "utf-8");
2210
2421
  }
2211
2422
  function defaultInstallExec(command, args, options) {
2212
2423
  execFileSync(command, args, {
@@ -2932,7 +3143,7 @@ function splitPathExt(pathext) {
2932
3143
  }
2933
3144
  const MAX_RETRIES = 2;
2934
3145
  const TOOL_RESULT_PREVIEW_LIMIT = 400;
2935
- const ASSISTANT_TEXT_EVENT_LIMIT = 8e3;
3146
+ const ASSISTANT_TEXT_EVENT_LIMIT$1 = 8e3;
2936
3147
  const SUPPORTED_IMAGE_MIMES = new Set(SUPPORTED_IMAGE_MIMES$1);
2937
3148
  const MIME_TO_EXT = {
2938
3149
  "image/png": "png",
@@ -3058,7 +3269,7 @@ function createToolCallProcessor(emit) {
3058
3269
  if (text.length === 0) continue;
3059
3270
  emit({
3060
3271
  kind: "assistant_text",
3061
- payload: { text: text.slice(0, ASSISTANT_TEXT_EVENT_LIMIT) }
3272
+ payload: { text: text.slice(0, ASSISTANT_TEXT_EVENT_LIMIT$1) }
3062
3273
  });
3063
3274
  } else if (isThinkingBlock(block)) emit({
3064
3275
  kind: "thinking",
@@ -3602,6 +3813,457 @@ function generateClaudeMd(workspacePath, identity, contextTreePath) {
3602
3813
  }
3603
3814
  writeFileSync(join(workspacePath, "CLAUDE.md"), sections.join("\n"), "utf-8");
3604
3815
  }
3816
+ const ASSISTANT_TEXT_EVENT_LIMIT = 8e3;
3817
+ const RESULT_PREVIEW_LIMIT = 400;
3818
+ /**
3819
+ * Build the per-turn `ThreadOptions` Codex consumes. Exported so unit tests
3820
+ * can lock the auth-mode-friendly defaults (notably `model` only set when
3821
+ * the operator chose one).
3822
+ */
3823
+ function buildCodexThreadOptions(payload, workspaceCwd) {
3824
+ const additionalDirectories = [];
3825
+ for (const repo of payload.gitRepos) {
3826
+ const localPath = repo.localPath ?? deriveRepoLocalPath(repo.url);
3827
+ if (!localPath) continue;
3828
+ additionalDirectories.push(join(workspaceCwd, localPath));
3829
+ }
3830
+ const opts = {
3831
+ workingDirectory: workspaceCwd,
3832
+ skipGitRepoCheck: true,
3833
+ sandboxMode: "workspace-write",
3834
+ approvalPolicy: "never",
3835
+ modelReasoningEffort: "high",
3836
+ webSearchEnabled: false,
3837
+ additionalDirectories
3838
+ };
3839
+ if (payload.model) opts.model = payload.model;
3840
+ return opts;
3841
+ }
3842
+ /**
3843
+ * Codex Handler — session-oriented handler using `@openai/codex-sdk`.
3844
+ *
3845
+ * Each instance owns one Thread for one chat. Each turn is a fresh
3846
+ * `runStreamed()` call (Codex CLI is run-to-completion per turn). Inject
3847
+ * during an active turn buffers messages and runs them as a follow-up turn
3848
+ * the moment the current one completes.
3849
+ *
3850
+ * Key footguns observed end-to-end (private plan §10.7):
3851
+ * - F1: providing `env` to Codex SDK does NOT inherit `process.env`; we
3852
+ * explicitly merge.
3853
+ * - F2: `resumeThread(id)` does NOT inherit `ThreadOptions`; we re-pass
3854
+ * them every time.
3855
+ * - F3: `modelReasoningEffort: "minimal"` is incompatible with default
3856
+ * tools; we default to `"high"` with `webSearchEnabled: false`.
3857
+ * - F6: `Thread` has no close/dispose — shutdown is exclusively
3858
+ * `AbortController.abort()`.
3859
+ */
3860
+ const createCodexHandler = (config) => {
3861
+ const workspaceRoot = config.workspaceRoot;
3862
+ const agentConfigCache = config.agentConfigCache ?? null;
3863
+ const gitMirrorManager = config.gitMirrorManager ?? null;
3864
+ const contextTreePath = config.contextTreePath ?? null;
3865
+ let cwd = null;
3866
+ let codex = null;
3867
+ let thread = null;
3868
+ let threadId = null;
3869
+ let currentAbort = null;
3870
+ let currentTurnPromise = null;
3871
+ let ctx = null;
3872
+ let drainScheduled = false;
3873
+ const queuedMessages = [];
3874
+ const ownedWorktrees = [];
3875
+ function buildEnv(sessionCtx) {
3876
+ const env = {};
3877
+ for (const [k, v] of Object.entries(process.env)) if (typeof v === "string") env[k] = v;
3878
+ const payload = agentConfigCache?.get(sessionCtx.agent.agentId)?.payload;
3879
+ if (payload) for (const e of payload.env) env[e.key] = e.value;
3880
+ const merged = sessionCtx.buildAgentEnv(env);
3881
+ const out = {};
3882
+ for (const [k, v] of Object.entries(merged)) if (typeof v === "string") out[k] = v;
3883
+ return out;
3884
+ }
3885
+ function buildCodexConfig(payload) {
3886
+ const cfg = { project_root_markers: [FIRST_TREE_WORKSPACE_MARKER] };
3887
+ if (payload.mcpServers.length === 0) return cfg;
3888
+ const mcpServers = {};
3889
+ for (const m of payload.mcpServers) if (m.transport === "stdio") mcpServers[m.name] = {
3890
+ command: m.command,
3891
+ args: m.args ?? []
3892
+ };
3893
+ else {
3894
+ const entry = { url: m.url };
3895
+ if (m.headers) entry.headers = m.headers;
3896
+ mcpServers[m.name] = entry;
3897
+ }
3898
+ cfg.mcp_servers = mcpServers;
3899
+ return cfg;
3900
+ }
3901
+ function buildAgentBriefing(payload) {
3902
+ const lines = [];
3903
+ lines.push("# Agent Briefing");
3904
+ lines.push("");
3905
+ if (payload.prompt.append.trim()) {
3906
+ lines.push(payload.prompt.append.trim());
3907
+ lines.push("");
3908
+ }
3909
+ lines.push("Refer to `.agent/identity.json` for your agent identity, `.agent/tools.md` for the");
3910
+ lines.push("first-tree-hub SDK reference, and `.agent/context/` for organisational context");
3911
+ lines.push("(when configured).");
3912
+ return lines.join("\n").concat("\n");
3913
+ }
3914
+ function toCodexInput(message, sessionCtx) {
3915
+ return sessionCtx.formatInboundContent(message).then((text) => text);
3916
+ }
3917
+ async function prepareGitWorktrees(payload, workspaceCwd, chatId) {
3918
+ if (!gitMirrorManager) return;
3919
+ for (const repo of payload.gitRepos) {
3920
+ const localPath = repo.localPath ?? deriveRepoLocalPath(repo.url);
3921
+ if (!localPath) continue;
3922
+ const targetPath = join(workspaceCwd, localPath);
3923
+ if (existsSync(targetPath)) continue;
3924
+ try {
3925
+ await gitMirrorManager.ensureMirror(repo.url);
3926
+ await gitMirrorManager.fetchMirror(repo.url);
3927
+ const result = await gitMirrorManager.createWorktree({
3928
+ url: repo.url,
3929
+ ref: repo.ref,
3930
+ targetPath,
3931
+ sessionKey: chatId
3932
+ });
3933
+ ownedWorktrees.push({
3934
+ url: repo.url,
3935
+ path: targetPath,
3936
+ branchName: result.branchName
3937
+ });
3938
+ } catch (err) {
3939
+ const msg = err instanceof Error ? err.message : String(err);
3940
+ ctx?.log(`codex git materialisation skipped (${repo.url}): ${msg}`);
3941
+ }
3942
+ }
3943
+ }
3944
+ function emitToolCall(sessionCtx, payload) {
3945
+ const event = {
3946
+ kind: "tool_call",
3947
+ payload: {
3948
+ toolUseId: payload.toolUseId,
3949
+ name: payload.name,
3950
+ args: payload.args,
3951
+ status: payload.status,
3952
+ ...payload.resultPreview ? { resultPreview: payload.resultPreview.slice(0, RESULT_PREVIEW_LIMIT) } : {}
3953
+ }
3954
+ };
3955
+ sessionCtx.emitEvent(event);
3956
+ }
3957
+ /**
3958
+ * Translate one terminal `item.completed` payload into the runtime's event
3959
+ * stream and, when the item is the assistant's final message, return the
3960
+ * raw text so `runTurn` can stitch the per-turn reply together.
3961
+ */
3962
+ function processItem(item, sessionCtx) {
3963
+ switch (item.type) {
3964
+ case "agent_message":
3965
+ if (!item.text.trim()) return "";
3966
+ sessionCtx.emitEvent({
3967
+ kind: "assistant_text",
3968
+ payload: { text: item.text.slice(0, ASSISTANT_TEXT_EVENT_LIMIT) }
3969
+ });
3970
+ return item.text;
3971
+ case "command_execution": {
3972
+ const status = item.status === "completed" ? "ok" : item.status === "failed" ? "error" : "pending";
3973
+ emitToolCall(sessionCtx, {
3974
+ toolUseId: item.id,
3975
+ name: "command",
3976
+ args: { command: item.command },
3977
+ status,
3978
+ resultPreview: item.aggregated_output
3979
+ });
3980
+ return "";
3981
+ }
3982
+ case "file_change": {
3983
+ const status = item.status === "completed" ? "ok" : "error";
3984
+ emitToolCall(sessionCtx, {
3985
+ toolUseId: item.id,
3986
+ name: "file_change",
3987
+ args: { changes: item.changes },
3988
+ status
3989
+ });
3990
+ return "";
3991
+ }
3992
+ case "mcp_tool_call": {
3993
+ const status = item.status === "completed" ? "ok" : item.status === "failed" ? "error" : "pending";
3994
+ const resultPreview = item.error ? `error: ${item.error.message}` : item.result ? JSON.stringify(item.result.structured_content ?? item.result.content) : void 0;
3995
+ emitToolCall(sessionCtx, {
3996
+ toolUseId: item.id,
3997
+ name: `mcp:${item.server}/${item.tool}`,
3998
+ args: item.arguments,
3999
+ status,
4000
+ resultPreview
4001
+ });
4002
+ return "";
4003
+ }
4004
+ case "web_search":
4005
+ emitToolCall(sessionCtx, {
4006
+ toolUseId: item.id,
4007
+ name: "web_search",
4008
+ args: { query: item.query },
4009
+ status: "ok"
4010
+ });
4011
+ return "";
4012
+ case "todo_list":
4013
+ emitToolCall(sessionCtx, {
4014
+ toolUseId: item.id,
4015
+ name: "todo_list",
4016
+ args: { items: item.items },
4017
+ status: "ok"
4018
+ });
4019
+ return "";
4020
+ case "reasoning":
4021
+ sessionCtx.emitEvent({
4022
+ kind: "thinking",
4023
+ payload: {}
4024
+ });
4025
+ return "";
4026
+ case "error":
4027
+ sessionCtx.emitEvent({
4028
+ kind: "error",
4029
+ payload: {
4030
+ source: "tool",
4031
+ message: item.message
4032
+ }
4033
+ });
4034
+ return "";
4035
+ default: return "";
4036
+ }
4037
+ }
4038
+ async function runTurn(input, sessionCtx) {
4039
+ const activeThread = thread;
4040
+ if (!activeThread) return;
4041
+ const abort = new AbortController();
4042
+ currentAbort = abort;
4043
+ sessionCtx.setRuntimeState("working");
4044
+ const assistantTexts = [];
4045
+ let turnFailed = false;
4046
+ const promise = (async () => {
4047
+ try {
4048
+ const streamed = await activeThread.runStreamed(input, { signal: abort.signal });
4049
+ for await (const event of streamed.events) {
4050
+ if (abort.signal.aborted) break;
4051
+ sessionCtx.touch();
4052
+ if (event.type === "thread.started") threadId = event.thread_id;
4053
+ else if (event.type === "turn.started") {} else if (event.type === "item.completed") {
4054
+ const text = processItem(event.item, sessionCtx);
4055
+ if (text) assistantTexts.push(text);
4056
+ } else if (event.type === "item.started" || event.type === "item.updated") {} else if (event.type === "turn.completed") {} else if (event.type === "turn.failed") {
4057
+ turnFailed = true;
4058
+ sessionCtx.emitEvent({
4059
+ kind: "error",
4060
+ payload: {
4061
+ source: "sdk",
4062
+ message: event.error.message
4063
+ }
4064
+ });
4065
+ } else if (event.type === "error") sessionCtx.emitEvent({
4066
+ kind: "error",
4067
+ payload: {
4068
+ source: "sdk",
4069
+ message: event.message
4070
+ }
4071
+ });
4072
+ }
4073
+ } catch (err) {
4074
+ if (abort.signal.aborted) return;
4075
+ turnFailed = true;
4076
+ const msg = err instanceof Error ? err.message : String(err);
4077
+ sessionCtx.emitEvent({
4078
+ kind: "error",
4079
+ payload: {
4080
+ source: "sdk",
4081
+ message: msg
4082
+ }
4083
+ });
4084
+ }
4085
+ })();
4086
+ currentTurnPromise = promise;
4087
+ try {
4088
+ await promise;
4089
+ } finally {
4090
+ currentAbort = null;
4091
+ currentTurnPromise = null;
4092
+ }
4093
+ if (abort.signal.aborted) return;
4094
+ const accumulated = assistantTexts.join("\n\n");
4095
+ let forwardFailed = false;
4096
+ if (accumulated.trim()) try {
4097
+ await sessionCtx.forwardResult(accumulated);
4098
+ } catch (err) {
4099
+ forwardFailed = true;
4100
+ const msg = err instanceof Error ? err.message : String(err);
4101
+ sessionCtx.emitEvent({
4102
+ kind: "error",
4103
+ payload: {
4104
+ source: "runtime",
4105
+ message: `forwardResult failed: ${msg}`
4106
+ }
4107
+ });
4108
+ }
4109
+ const succeeded = !turnFailed && !forwardFailed;
4110
+ sessionCtx.emitEvent({
4111
+ kind: "turn_end",
4112
+ payload: { status: succeeded ? "success" : "error" }
4113
+ });
4114
+ if (succeeded && accumulated.trim()) sessionCtx.reportSessionCompletion();
4115
+ sessionCtx.setRuntimeState("idle");
4116
+ if (queuedMessages.length > 0 && !drainScheduled) {
4117
+ drainScheduled = true;
4118
+ setImmediate(() => {
4119
+ drainScheduled = false;
4120
+ const drained = queuedMessages.splice(0);
4121
+ if (drained.length === 0 || !ctx || !thread) return;
4122
+ mergeAndRun(drained, ctx);
4123
+ });
4124
+ }
4125
+ }
4126
+ async function mergeAndRun(drained, sessionCtx) {
4127
+ const inputs = [];
4128
+ for (const m of drained) try {
4129
+ inputs.push(await sessionCtx.formatInboundContent(m));
4130
+ } catch (err) {
4131
+ sessionCtx.log(`codex inject formatInboundContent failed: ${err instanceof Error ? err.message : String(err)}`);
4132
+ }
4133
+ if (inputs.length === 0) return;
4134
+ await runTurn(inputs.join("\n\n"), sessionCtx);
4135
+ }
4136
+ return {
4137
+ async start(message, sessionCtx) {
4138
+ ctx = sessionCtx;
4139
+ cwd = await acquireWorkspace(workspaceRoot, sessionCtx.chatId);
4140
+ let payload = null;
4141
+ if (agentConfigCache) payload = (await agentConfigCache.refresh(sessionCtx.agent.agentId)).payload;
4142
+ if (!payload) payload = {
4143
+ kind: "codex",
4144
+ prompt: { append: "" },
4145
+ model: "",
4146
+ mcpServers: [],
4147
+ env: [],
4148
+ gitRepos: []
4149
+ };
4150
+ bootstrapWorkspace({
4151
+ workspacePath: cwd,
4152
+ identity: sessionCtx.agent,
4153
+ contextTreePath,
4154
+ serverUrl: sessionCtx.sdk.serverUrl,
4155
+ chatId: sessionCtx.chatId,
4156
+ briefing: {
4157
+ format: "agents-md",
4158
+ content: buildAgentBriefing(payload)
4159
+ }
4160
+ });
4161
+ await prepareGitWorktrees(payload, cwd, sessionCtx.chatId);
4162
+ codex = new Codex({
4163
+ env: buildEnv(sessionCtx),
4164
+ config: buildCodexConfig(payload)
4165
+ });
4166
+ thread = codex.startThread(buildCodexThreadOptions(payload, cwd));
4167
+ await runTurn(await toCodexInput(message, sessionCtx), sessionCtx);
4168
+ if (!threadId) threadId = thread.id ?? null;
4169
+ if (!threadId) throw new Error("codex did not assign a thread id during the first turn");
4170
+ return threadId;
4171
+ },
4172
+ async resume(message, sessionId, sessionCtx) {
4173
+ ctx = sessionCtx;
4174
+ cwd = await acquireWorkspace(workspaceRoot, sessionCtx.chatId);
4175
+ let payload = null;
4176
+ if (agentConfigCache) payload = (await agentConfigCache.refresh(sessionCtx.agent.agentId)).payload;
4177
+ if (!payload) payload = {
4178
+ kind: "codex",
4179
+ prompt: { append: "" },
4180
+ model: "",
4181
+ mcpServers: [],
4182
+ env: [],
4183
+ gitRepos: []
4184
+ };
4185
+ bootstrapWorkspace({
4186
+ workspacePath: cwd,
4187
+ identity: sessionCtx.agent,
4188
+ contextTreePath,
4189
+ serverUrl: sessionCtx.sdk.serverUrl,
4190
+ chatId: sessionCtx.chatId,
4191
+ briefing: {
4192
+ format: "agents-md",
4193
+ content: buildAgentBriefing(payload)
4194
+ }
4195
+ });
4196
+ await prepareGitWorktrees(payload, cwd, sessionCtx.chatId);
4197
+ codex = new Codex({
4198
+ env: buildEnv(sessionCtx),
4199
+ config: buildCodexConfig(payload)
4200
+ });
4201
+ thread = codex.resumeThread(sessionId, buildCodexThreadOptions(payload, cwd));
4202
+ threadId = sessionId;
4203
+ if (message) await runTurn(await toCodexInput(message, sessionCtx), sessionCtx);
4204
+ return sessionId;
4205
+ },
4206
+ inject(message) {
4207
+ if (currentTurnPromise) {
4208
+ queuedMessages.push(message);
4209
+ return;
4210
+ }
4211
+ const sessionCtx = ctx;
4212
+ if (!sessionCtx || !thread) return;
4213
+ (async () => {
4214
+ try {
4215
+ await runTurn(await toCodexInput(message, sessionCtx), sessionCtx);
4216
+ } catch (err) {
4217
+ sessionCtx.log(`codex inject failed: ${err instanceof Error ? err.message : String(err)}`);
4218
+ }
4219
+ })();
4220
+ },
4221
+ async suspend() {
4222
+ currentAbort?.abort();
4223
+ try {
4224
+ await currentTurnPromise;
4225
+ } catch {}
4226
+ currentAbort = null;
4227
+ currentTurnPromise = null;
4228
+ thread = null;
4229
+ codex = null;
4230
+ },
4231
+ async shutdown() {
4232
+ currentAbort?.abort();
4233
+ try {
4234
+ await currentTurnPromise;
4235
+ } catch {}
4236
+ currentAbort = null;
4237
+ currentTurnPromise = null;
4238
+ thread = null;
4239
+ codex = null;
4240
+ if (gitMirrorManager) {
4241
+ for (const wt of ownedWorktrees) try {
4242
+ await gitMirrorManager.removeWorktree({
4243
+ url: wt.url,
4244
+ path: wt.path,
4245
+ branchName: wt.branchName
4246
+ });
4247
+ } catch (err) {
4248
+ ctx?.log(`codex worktree cleanup failed (${wt.path}): ${err instanceof Error ? err.message : String(err)}`);
4249
+ }
4250
+ ownedWorktrees.length = 0;
4251
+ }
4252
+ if (cwd && existsSync(cwd)) try {
4253
+ rmSync(cwd, {
4254
+ recursive: true,
4255
+ force: true
4256
+ });
4257
+ } catch (err) {
4258
+ ctx?.log(`codex workspace cleanup failed: ${err instanceof Error ? err.message : String(err)}`);
4259
+ }
4260
+ cwd = null;
4261
+ threadId = null;
4262
+ ctx = null;
4263
+ queuedMessages.length = 0;
4264
+ }
4265
+ };
4266
+ };
3605
4267
  /** Register all built-in handlers. Call once at startup. */
3606
4268
  function registerBuiltinHandlers() {
3607
4269
  const resolution = resolveClaudeCodeExecutable();
@@ -3611,6 +4273,7 @@ function registerBuiltinHandlers() {
3611
4273
  ...config,
3612
4274
  claudeCodeExecutable: resolution.path
3613
4275
  }));
4276
+ registerHandler("codex", (config) => createCodexHandler(config));
3614
4277
  }
3615
4278
  function createAgentConfigCache(opts) {
3616
4279
  const { sdk } = opts;
@@ -3645,6 +4308,7 @@ function createAgentConfigCache(opts) {
3645
4308
  agentId,
3646
4309
  version: 0,
3647
4310
  payload: {
4311
+ kind: "claude-code",
3648
4312
  prompt: { append: "" },
3649
4313
  model: "",
3650
4314
  mcpServers: [],
@@ -4325,11 +4989,19 @@ var SessionManager = class {
4325
4989
  this.lastReportedStates.set(chatId, state);
4326
4990
  this.config.onStateChange(chatId, state);
4327
4991
  }
4328
- /** ACK an inbox entry — delayed until handler starts processing. */
4992
+ /**
4993
+ * ACK an inbox entry — delayed until handler starts processing.
4994
+ *
4995
+ * Routes through `config.ackEntry` when set (WS push path) or falls back to
4996
+ * `sdk.ack` (HTTP poll path). One ack per entry, one channel per slot —
4997
+ * mixing channels in one slot would leak the server's per-agent in-flight
4998
+ * counter (proposal hub-inbox-ws-data-plane §3.5).
4999
+ */
4329
5000
  async ackEntry(entryId, chatId) {
4330
5001
  if (entryId === void 0) return;
4331
5002
  try {
4332
- await this.config.sdk.ack(entryId);
5003
+ if (this.config.ackEntry) await this.config.ackEntry(entryId);
5004
+ else await this.config.sdk.ack(entryId);
4333
5005
  } catch {
4334
5006
  this.config.log.warn({
4335
5007
  chatId,
@@ -4480,6 +5152,12 @@ var AgentSlot = class {
4480
5152
  pollingTimer = null;
4481
5153
  reconcileTimer = null;
4482
5154
  listeners = [];
5155
+ /**
5156
+ * The inbox this slot's agent owns — used to filter `inbox:deliver`
5157
+ * frames addressed to other agents on the same client. Captured at
5158
+ * `start()` from `sdk.register()`.
5159
+ */
5160
+ inboxId = null;
4483
5161
  constructor(config) {
4484
5162
  this.config = config;
4485
5163
  this.logger = createLogger("slot").child({
@@ -4522,9 +5200,19 @@ var AgentSlot = class {
4522
5200
  this.logger.error({ err }, "failed to fetch agent config — bind aborted");
4523
5201
  throw new Error(`Hub unreachable while loading agent config: ${msg}`);
4524
5202
  }
5203
+ this.inboxId = agent.inboxId;
4525
5204
  const onMessage = (agentId) => {
4526
5205
  if (agentId === this.config.agentId) this.pullAndDispatch();
4527
5206
  };
5207
+ const onInboxDeliver = (inboxId, frame) => {
5208
+ if (inboxId !== this.inboxId) return;
5209
+ this.dispatchPushedFrame(frame).catch((err) => {
5210
+ this.logger.warn({
5211
+ err,
5212
+ entryId: frame.entryId
5213
+ }, "inbox:deliver dispatch error");
5214
+ });
5215
+ };
4528
5216
  const onBound = (boundAgent) => {
4529
5217
  if (boundAgent.agentId === this.config.agentId) {
4530
5218
  this.fullStateSync();
@@ -4535,11 +5223,15 @@ var AgentSlot = class {
4535
5223
  if (result.agentId === this.config.agentId && this.sessionManager) this.sessionManager.applyStaleChatIds(result.staleChatIds);
4536
5224
  };
4537
5225
  this.clientConnection.on("agent:message", onMessage);
5226
+ this.clientConnection.on("inbox:deliver", onInboxDeliver);
4538
5227
  this.clientConnection.on("agent:bound", onBound);
4539
5228
  this.clientConnection.on("session:reconcile:result", onReconcileResult);
4540
5229
  this.listeners.push({
4541
5230
  event: "agent:message",
4542
5231
  fn: onMessage
5232
+ }, {
5233
+ event: "inbox:deliver",
5234
+ fn: onInboxDeliver
4543
5235
  }, {
4544
5236
  event: "agent:bound",
4545
5237
  fn: onBound
@@ -4555,6 +5247,10 @@ var AgentSlot = class {
4555
5247
  agentId: this.config.agentId
4556
5248
  })
4557
5249
  });
5250
+ const ackEntry = this.clientConnection.supportsWsInboxDeliver ? (entryId) => {
5251
+ this.clientConnection.sendInboxAck(entryId);
5252
+ return Promise.resolve();
5253
+ } : void 0;
4558
5254
  this.sessionManager = new SessionManager({
4559
5255
  session: this.config.session,
4560
5256
  concurrency: this.config.concurrency,
@@ -4576,6 +5272,7 @@ var AgentSlot = class {
4576
5272
  log: this.logger,
4577
5273
  registryPath,
4578
5274
  agentConfigCache: this.agentConfigCache,
5275
+ ackEntry,
4579
5276
  onStateChange: (chatId, state) => this.reportSessionState(chatId, state),
4580
5277
  onRuntimeStateChange: (state) => this.reportRuntimeState(state),
4581
5278
  onSessionEvent: (chatId, event) => this.reportSessionEvent(chatId, event),
@@ -4609,6 +5306,7 @@ var AgentSlot = class {
4609
5306
  this.reconcileTimer = null;
4610
5307
  }
4611
5308
  for (const entry of this.listeners) if (entry.event === "agent:message") this.clientConnection.off(entry.event, entry.fn);
5309
+ else if (entry.event === "inbox:deliver") this.clientConnection.off(entry.event, entry.fn);
4612
5310
  else if (entry.event === "agent:bound") this.clientConnection.off(entry.event, entry.fn);
4613
5311
  else if (entry.event === "session:reconcile:result") this.clientConnection.off(entry.event, entry.fn);
4614
5312
  else this.clientConnection.off(entry.event, entry.fn);
@@ -4636,11 +5334,47 @@ var AgentSlot = class {
4636
5334
  if (runtimeState) this.clientConnection.reportRuntimeState(this.config.agentId, runtimeState);
4637
5335
  }
4638
5336
  startPolling() {
5337
+ if (this.clientConnection.supportsWsInboxDeliver) {
5338
+ this.logger.info("WS inbox data plane active — skipping 5s HTTP poll");
5339
+ return;
5340
+ }
4639
5341
  this.pollingTimer = setInterval(() => {
4640
5342
  this.pullAndDispatch();
4641
5343
  }, 5e3);
4642
5344
  this.pullAndDispatch();
4643
5345
  }
5346
+ /**
5347
+ * Translate an `inbox:deliver` push frame into the {@link InboxEntryWithMessage}
5348
+ * shape `SessionManager.dispatch` expects, then dispatch.
5349
+ *
5350
+ * Ack happens INSIDE `dispatch` via the `ackEntry` callback we pinned at
5351
+ * construction time — for push slots that's `clientConnection.sendInboxAck`,
5352
+ * for poll slots it stays the legacy `sdk.ack`. Sending an additional ack
5353
+ * here would double-ack: HTTP first (`delivered → acked`) followed by a
5354
+ * WS frame the server can no longer match against any `delivered` row,
5355
+ * which leaks the server's per-agent in-flight counter and stalls push
5356
+ * after `inboxMaxInFlightPerAgent` messages.
5357
+ *
5358
+ * Dispatch errors propagate up; the entry stays `delivered` server-side
5359
+ * and the 300s timeout reaper rolls it back to `pending` for replay
5360
+ * (proposal §3.7).
5361
+ */
5362
+ async dispatchPushedFrame(frame) {
5363
+ if (!this.sessionManager) return;
5364
+ const entry = {
5365
+ id: frame.entryId,
5366
+ inboxId: frame.inboxId,
5367
+ messageId: frame.message.id,
5368
+ chatId: frame.chatId,
5369
+ status: "delivered",
5370
+ retryCount: 0,
5371
+ createdAt: frame.message.createdAt,
5372
+ deliveredAt: (/* @__PURE__ */ new Date()).toISOString(),
5373
+ ackedAt: null,
5374
+ message: frame.message
5375
+ };
5376
+ await this.sessionManager.dispatch(entry);
5377
+ }
4644
5378
  startReconcileLoop() {
4645
5379
  const intervalSec = this.config.session.reconcile_interval_seconds ?? 300;
4646
5380
  this.reconcileTimer = setInterval(() => this.reconcileNow(), intervalSec * 1e3);
@@ -4662,6 +5396,226 @@ var AgentSlot = class {
4662
5396
  }
4663
5397
  };
4664
5398
  /**
5399
+ * Top-level marker file Claude Code writes after a successful OAuth login.
5400
+ * Path is platform-agnostic (`~/.claude.json`); the access token itself lives
5401
+ * in the platform credential store (macOS Keychain entry "Claude Code-
5402
+ * credentials", or libsecret on Linux), so we treat the presence of an
5403
+ * `oauthAccount.accountUuid` field as the canonical "logged in" signal.
5404
+ */
5405
+ const CLAUDE_PROFILE_PATH = () => join(homedir(), ".claude.json");
5406
+ function hasClaudeOAuthAccount() {
5407
+ try {
5408
+ const path = CLAUDE_PROFILE_PATH();
5409
+ if (!existsSync(path)) return false;
5410
+ const raw = readFileSync(path, "utf-8");
5411
+ const obj = JSON.parse(raw);
5412
+ return typeof obj.oauthAccount?.accountUuid === "string" && obj.oauthAccount.accountUuid.length > 0;
5413
+ } catch {
5414
+ return false;
5415
+ }
5416
+ }
5417
+ async function readSdkVersion$1() {
5418
+ try {
5419
+ let dir = dirname(fileURLToPath(await import.meta.resolve("@anthropic-ai/claude-agent-sdk")));
5420
+ for (let depth = 0; depth < 8; depth += 1) {
5421
+ const candidate = join(dir, "package.json");
5422
+ if (existsSync(candidate)) {
5423
+ const pkg = JSON.parse(readFileSync(candidate, "utf-8"));
5424
+ if (pkg.name === "@anthropic-ai/claude-agent-sdk" && typeof pkg.version === "string") return pkg.version;
5425
+ }
5426
+ const parent = dirname(dir);
5427
+ if (parent === dir) break;
5428
+ dir = parent;
5429
+ }
5430
+ } catch {}
5431
+ return null;
5432
+ }
5433
+ function detectAuth$1() {
5434
+ if (process.env.ANTHROPIC_API_KEY && process.env.ANTHROPIC_API_KEY.length > 0) return {
5435
+ authenticated: true,
5436
+ method: "api_key"
5437
+ };
5438
+ if (hasClaudeOAuthAccount()) return {
5439
+ authenticated: true,
5440
+ method: "oauth"
5441
+ };
5442
+ return {
5443
+ authenticated: false,
5444
+ method: "none"
5445
+ };
5446
+ }
5447
+ /**
5448
+ * Probe whether the Claude Code runtime is usable on this machine.
5449
+ *
5450
+ * `state` is the authoritative field; `available` and `authenticated` are
5451
+ * derived booleans kept around for simple consumers (e.g. capability lookup
5452
+ * in service-layer guards).
5453
+ */
5454
+ async function probeClaudeCodeCapability() {
5455
+ const detectedAt = (/* @__PURE__ */ new Date()).toISOString();
5456
+ try {
5457
+ let sdkPresent = false;
5458
+ try {
5459
+ await import("@anthropic-ai/claude-agent-sdk");
5460
+ sdkPresent = true;
5461
+ } catch {
5462
+ sdkPresent = false;
5463
+ }
5464
+ if (!sdkPresent) return {
5465
+ state: "missing",
5466
+ available: false,
5467
+ authenticated: false,
5468
+ sdkVersion: null,
5469
+ authMethod: "none",
5470
+ detectedAt
5471
+ };
5472
+ const sdkVersion = await readSdkVersion$1();
5473
+ const auth = detectAuth$1();
5474
+ if (!auth.authenticated) return {
5475
+ state: "unauthenticated",
5476
+ available: true,
5477
+ authenticated: false,
5478
+ sdkVersion,
5479
+ authMethod: "none",
5480
+ detectedAt
5481
+ };
5482
+ return {
5483
+ state: "ok",
5484
+ available: true,
5485
+ authenticated: true,
5486
+ sdkVersion,
5487
+ authMethod: auth.method,
5488
+ detectedAt
5489
+ };
5490
+ } catch (err) {
5491
+ return {
5492
+ state: "error",
5493
+ available: false,
5494
+ authenticated: false,
5495
+ sdkVersion: null,
5496
+ authMethod: "none",
5497
+ error: err instanceof Error ? err.message : String(err),
5498
+ detectedAt
5499
+ };
5500
+ }
5501
+ }
5502
+ function codexAuthPath() {
5503
+ return join(process.env.CODEX_HOME ?? join(homedir(), ".codex"), "auth.json");
5504
+ }
5505
+ async function readSdkVersion() {
5506
+ try {
5507
+ let dir = dirname(fileURLToPath(await import.meta.resolve("@openai/codex-sdk")));
5508
+ for (let depth = 0; depth < 8; depth += 1) {
5509
+ const candidate = join(dir, "package.json");
5510
+ if (existsSync(candidate)) {
5511
+ const pkg = JSON.parse(readFileSync(candidate, "utf-8"));
5512
+ if (pkg.name === "@openai/codex-sdk" && typeof pkg.version === "string") return pkg.version;
5513
+ }
5514
+ const parent = dirname(dir);
5515
+ if (parent === dir) break;
5516
+ dir = parent;
5517
+ }
5518
+ } catch {}
5519
+ return null;
5520
+ }
5521
+ function detectAuth() {
5522
+ if (process.env.CODEX_API_KEY && process.env.CODEX_API_KEY.length > 0) return {
5523
+ authenticated: true,
5524
+ method: "api_key"
5525
+ };
5526
+ if (existsSync(codexAuthPath())) return {
5527
+ authenticated: true,
5528
+ method: "auth_json"
5529
+ };
5530
+ return {
5531
+ authenticated: false,
5532
+ method: "none"
5533
+ };
5534
+ }
5535
+ /**
5536
+ * Probe whether the OpenAI Codex runtime is usable on this machine.
5537
+ * Treats `~/.codex/auth.json` (set by `codex login`) as the canonical local
5538
+ * auth source; CODEX_API_KEY env shortcuts that for ephemeral use.
5539
+ */
5540
+ async function probeCodexCapability() {
5541
+ const detectedAt = (/* @__PURE__ */ new Date()).toISOString();
5542
+ try {
5543
+ let sdkPresent = false;
5544
+ try {
5545
+ await import("@openai/codex-sdk");
5546
+ sdkPresent = true;
5547
+ } catch {
5548
+ sdkPresent = false;
5549
+ }
5550
+ if (!sdkPresent) return {
5551
+ state: "missing",
5552
+ available: false,
5553
+ authenticated: false,
5554
+ sdkVersion: null,
5555
+ authMethod: "none",
5556
+ detectedAt
5557
+ };
5558
+ const sdkVersion = await readSdkVersion();
5559
+ const auth = detectAuth();
5560
+ if (!auth.authenticated) return {
5561
+ state: "unauthenticated",
5562
+ available: true,
5563
+ authenticated: false,
5564
+ sdkVersion,
5565
+ authMethod: "none",
5566
+ detectedAt
5567
+ };
5568
+ return {
5569
+ state: "ok",
5570
+ available: true,
5571
+ authenticated: true,
5572
+ sdkVersion,
5573
+ authMethod: auth.method,
5574
+ detectedAt
5575
+ };
5576
+ } catch (err) {
5577
+ return {
5578
+ state: "error",
5579
+ available: false,
5580
+ authenticated: false,
5581
+ sdkVersion: null,
5582
+ authMethod: "none",
5583
+ error: err instanceof Error ? err.message : String(err),
5584
+ detectedAt
5585
+ };
5586
+ }
5587
+ }
5588
+ /**
5589
+ * Run every built-in capability probe and aggregate the results.
5590
+ *
5591
+ * Each provider gets its own module under this directory; the orchestrator
5592
+ * is intentionally simple — adding a new provider means importing the new
5593
+ * probe here and registering its key. The probe modules themselves are
5594
+ * deliberately not part of the `HandlerFactory` interface so capability
5595
+ * detection stays decoupled from runtime instantiation (so we can probe
5596
+ * whether a runtime is usable before spawning anything).
5597
+ */
5598
+ async function probeCapabilities() {
5599
+ const probes = [["claude-code", probeClaudeCodeCapability()], ["codex", probeCodexCapability()]];
5600
+ const out = {};
5601
+ await Promise.all(probes.map(async ([provider, p]) => {
5602
+ try {
5603
+ out[provider] = await p;
5604
+ } catch (err) {
5605
+ out[provider] = {
5606
+ state: "error",
5607
+ available: false,
5608
+ authenticated: false,
5609
+ sdkVersion: null,
5610
+ authMethod: "none",
5611
+ error: err instanceof Error ? err.message : String(err),
5612
+ detectedAt: (/* @__PURE__ */ new Date()).toISOString()
5613
+ };
5614
+ }
5615
+ }));
5616
+ return out;
5617
+ }
5618
+ /**
4665
5619
  * Runtime-wide constants (Step 7 + Step 11).
4666
5620
  *
4667
5621
  * After Step 11 these values are fixed at code level — the previously
@@ -5285,7 +6239,7 @@ var ClientRuntime = class {
5285
6239
  });
5286
6240
  const yaml = stringify({
5287
6241
  agentId: message.agentId,
5288
- runtime: "claude-code"
6242
+ runtime: message.runtimeProvider
5289
6243
  });
5290
6244
  writeFileSync(join(agentDir, "agent.yaml"), yaml, { mode: 384 });
5291
6245
  print.check(true, `auto-added agent "${localName}"`, `${message.agentId} (from server push)`);
@@ -6598,7 +7552,7 @@ async function onboardCreate(args) {
6598
7552
  }
6599
7553
  const runtimeAgent = args.type === "human" ? args.assistant : args.id;
6600
7554
  if (args.feishuBotAppId && args.feishuBotAppSecret) {
6601
- const { bindFeishuBot } = await import("./feishu-FTWnoOsc.mjs").then((n) => n.r);
7555
+ const { bindFeishuBot } = await import("./feishu-DvjRZMdZ.mjs").then((n) => n.r);
6602
7556
  const targetAgentUuid = args.type === "human" ? assistantUuid : primary.uuid;
6603
7557
  if (!targetAgentUuid) print.line(`Warning: Cannot bind Feishu bot — no runtime agent available for "${args.id}".\n`);
6604
7558
  else {
@@ -6761,6 +7715,66 @@ function setNestedByDot(obj, dotPath, value) {
6761
7715
  if (lastKey !== void 0) current[lastKey] = value;
6762
7716
  }
6763
7717
  //#endregion
7718
+ //#region src/core/runtime-provider-reconcile.ts
7719
+ /**
7720
+ * Pre-flight reconciliation called before the agents loop spawns. Pulls
7721
+ * authoritative `runtime_provider` for every agent the calling user owns and
7722
+ * rewrites any local `agent.yaml` whose `runtime` field disagrees. Best-
7723
+ * effort: a transient hub failure logs and falls back to the local YAML
7724
+ * value (the in-band repair path catches any remaining drift on first bind).
7725
+ */
7726
+ async function reconcileLocalRuntimeProviders(opts) {
7727
+ const res = await fetch(`${opts.serverUrl}/api/v1/clients/me/agents`, { headers: { Authorization: `Bearer ${opts.accessToken}` } });
7728
+ if (!res.ok) throw new Error(`hub returned ${res.status} on /clients/me/agents`);
7729
+ const items = await res.json();
7730
+ const byAgentId = new Map(items.map((it) => [it.agentId, it]));
7731
+ if (!existsSync(opts.agentsDir)) return;
7732
+ const subdirs = readdirSync(opts.agentsDir, { withFileTypes: true }).filter((d) => d.isDirectory());
7733
+ for (const subdir of subdirs) {
7734
+ const yamlPath = join(opts.agentsDir, subdir.name, "agent.yaml");
7735
+ if (!existsSync(yamlPath)) continue;
7736
+ let parsed;
7737
+ try {
7738
+ parsed = parse(readFileSync(yamlPath, "utf-8")) ?? {};
7739
+ } catch (err) {
7740
+ const msg = err instanceof Error ? err.message : String(err);
7741
+ opts.log?.("warn", `agent ${subdir.name}: cannot parse yaml — ${msg}`);
7742
+ continue;
7743
+ }
7744
+ if (!parsed.agentId) continue;
7745
+ const auth = byAgentId.get(parsed.agentId);
7746
+ if (!auth) continue;
7747
+ if (parsed.runtime === auth.runtimeProvider) continue;
7748
+ const next = {
7749
+ ...parsed,
7750
+ runtime: auth.runtimeProvider
7751
+ };
7752
+ try {
7753
+ writeFileSync(yamlPath, stringify(next), { mode: 384 });
7754
+ opts.log?.("info", `agent ${parsed.agentId}: yaml runtime "${parsed.runtime ?? "(unset)"}" → "${auth.runtimeProvider}" (hub authoritative)`);
7755
+ } catch (err) {
7756
+ const msg = err instanceof Error ? err.message : String(err);
7757
+ opts.log?.("warn", `agent ${parsed.agentId}: failed to rewrite yaml — ${msg}`);
7758
+ }
7759
+ }
7760
+ }
7761
+ /**
7762
+ * Member-scoped capabilities upload. Server stores the snapshot under
7763
+ * `clients.metadata.capabilities`. Best-effort: failure does not block
7764
+ * client startup since capabilities only matter for UI / admin checks.
7765
+ */
7766
+ async function uploadClientCapabilities(opts) {
7767
+ const res = await fetch(`${opts.serverUrl}/api/v1/clients/${encodeURIComponent(opts.clientId)}/capabilities`, {
7768
+ method: "PATCH",
7769
+ headers: {
7770
+ Authorization: `Bearer ${opts.accessToken}`,
7771
+ "Content-Type": "application/json"
7772
+ },
7773
+ body: JSON.stringify({ capabilities: opts.capabilities })
7774
+ });
7775
+ if (!res.ok) throw new Error(`hub returned ${res.status} on PATCH /clients/${opts.clientId}/capabilities`);
7776
+ }
7777
+ //#endregion
6764
7778
  //#region ../../node_modules/.pnpm/hearback-core@0.1.1/node_modules/hearback-core/dist/schema.js
6765
7779
  const FeedbackType = z.enum(["bug", "feature"]);
6766
7780
  const BrowserContext = z.object({
@@ -7518,7 +8532,7 @@ function createFeedbackHandler(config) {
7518
8532
  return { handle };
7519
8533
  }
7520
8534
  //#endregion
7521
- //#region ../server/dist/app-BvSSa9Ak.mjs
8535
+ //#region ../server/dist/app-fbgPnPWI.mjs
7522
8536
  var __defProp = Object.defineProperty;
7523
8537
  var __exportAll = (all, no_symbols) => {
7524
8538
  let target = {};
@@ -7572,6 +8586,7 @@ const agents = pgTable("agents", {
7572
8586
  metadata: jsonb("metadata").$type().notNull().default({}),
7573
8587
  managerId: text("manager_id").notNull(),
7574
8588
  clientId: text("client_id").references(() => clients.id, { onDelete: "restrict" }),
8589
+ runtimeProvider: text("runtime_provider").notNull().default("claude-code"),
7575
8590
  createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
7576
8591
  updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
7577
8592
  }, (table) => [
@@ -8241,7 +9256,7 @@ async function deleteAdapterConfig(db, id) {
8241
9256
  const [row] = await db.delete(adapterConfigs).where(eq(adapterConfigs.id, id)).returning();
8242
9257
  if (!row) throw new NotFoundError(`Adapter config "${id}" not found`);
8243
9258
  }
8244
- const log$4 = createLogger$1("AdminAdapters");
9259
+ const log$5 = createLogger$1("AdminAdapters");
8245
9260
  function parseId(raw) {
8246
9261
  const id = Number(raw);
8247
9262
  if (!Number.isInteger(id) || id <= 0) throw new BadRequestError(`Invalid adapter ID: "${raw}"`);
@@ -8261,7 +9276,7 @@ async function adminAdapterRoutes(app) {
8261
9276
  const scope = memberScope(request);
8262
9277
  await assertCanManage(app.db, scope, body.agentId);
8263
9278
  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"));
9279
+ app.adapterManager.reload().catch((err) => log$5.error({ err }, "adapter reload failed after create"));
8265
9280
  app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
8266
9281
  return reply.status(201).send({
8267
9282
  ...config,
@@ -8285,7 +9300,7 @@ async function adminAdapterRoutes(app) {
8285
9300
  const existing = await getAdapterConfig(app.db, id);
8286
9301
  await assertCanManage(app.db, scope, existing.agentId);
8287
9302
  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"));
9303
+ app.adapterManager.reload().catch((err) => log$5.error({ err }, "adapter reload failed after update"));
8289
9304
  app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
8290
9305
  return {
8291
9306
  ...config,
@@ -8299,7 +9314,7 @@ async function adminAdapterRoutes(app) {
8299
9314
  const existing = await getAdapterConfig(app.db, id);
8300
9315
  await assertCanManage(app.db, scope, existing.agentId);
8301
9316
  await deleteAdapterConfig(app.db, id);
8302
- app.adapterManager.reload().catch((err) => log$4.error({ err }, "adapter reload failed after delete"));
9317
+ app.adapterManager.reload().catch((err) => log$5.error({ err }, "adapter reload failed after delete"));
8303
9318
  app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
8304
9319
  return reply.status(204).send();
8305
9320
  });
@@ -8400,6 +9415,38 @@ const members = pgTable("members", {
8400
9415
  * real account.
8401
9416
  */
8402
9417
  const RESERVED_AGENT_NAME_PREFIX = "__";
9418
+ /**
9419
+ * True iff `clients.metadata.capabilities` is a non-empty object — i.e. the
9420
+ * client has reported at least one runtime probe result. Used to distinguish
9421
+ * "we don't know what's installed yet" (empty / never reported) from
9422
+ * "client explicitly reports this provider is missing".
9423
+ */
9424
+ function clientCapabilitiesReported(metadata) {
9425
+ if (!metadata || typeof metadata !== "object") return false;
9426
+ const caps = metadata.capabilities;
9427
+ if (!caps || typeof caps !== "object") return false;
9428
+ return Object.keys(caps).length > 0;
9429
+ }
9430
+ /**
9431
+ * Inspect a `clients.metadata.capabilities` blob (jsonb) for a specific
9432
+ * runtime provider entry. Capabilities live under the `metadata.capabilities`
9433
+ * subkey (Option C); the column is unstructured at the DB layer, so we
9434
+ * defensively narrow before key access.
9435
+ *
9436
+ * "Supports" requires the entry's SDK to be **available** — `state: "ok"` or
9437
+ * `state: "unauthenticated"`. A `missing` or `error` entry is *reported* but
9438
+ * not usable, so we explicitly reject those rather than treating mere key
9439
+ * presence as support. Auth state is left to the user to fix at runtime
9440
+ * (the re-bind dialog surfaces an `unauthenticated` hint).
9441
+ */
9442
+ function clientSupportsRuntimeProvider(metadata, provider) {
9443
+ if (!metadata || typeof metadata !== "object") return false;
9444
+ const caps = metadata.capabilities;
9445
+ if (!caps || typeof caps !== "object") return false;
9446
+ const entry = caps[provider];
9447
+ if (!entry || typeof entry !== "object") return false;
9448
+ return entry.available === true;
9449
+ }
8403
9450
  /** Default visibility per agent type. */
8404
9451
  function defaultVisibility(type) {
8405
9452
  switch (type) {
@@ -8420,6 +9467,32 @@ function defaultVisibility(type) {
8420
9467
  * - When a non-human agent IS created with a `clientId`, the pinned client
8421
9468
  * must already be owned by the manager's user (Rule R-RUN).
8422
9469
  */
9470
+ /**
9471
+ * Check that a client's reported capabilities show the given runtime provider
9472
+ * as **available** (SDK installed, regardless of auth state).
9473
+ *
9474
+ * Tri-state semantics by `clients.metadata.capabilities` shape:
9475
+ * - empty / absent — client hasn't probed yet (newly registered or pre-P2
9476
+ * install). Treat as "unknown" and allow; the in-band repair path
9477
+ * (RUNTIME_PROVIDER_MISMATCH on bind) catches actual incompatibility.
9478
+ * - reported, entry shows `state: ok | unauthenticated` (i.e. `available:
9479
+ * true`) — allow.
9480
+ * - reported, entry missing OR `state: missing | error` — block unless
9481
+ * `force` is set. We deliberately do NOT treat mere key presence as
9482
+ * support: probeCapabilities() always emits an entry per built-in
9483
+ * provider, including `{ state: "missing" }` for absent SDKs.
9484
+ *
9485
+ * Skipped entirely for human agents (no clientId) and when `force` is set
9486
+ * (e.g. operator overrides for an offline client).
9487
+ */
9488
+ async function ensureClientSupportsRuntimeProvider(db, clientId, runtimeProvider, options = {}) {
9489
+ if (clientId === null) return;
9490
+ if (options.force) return;
9491
+ const [client] = await db.select({ metadata: clients.metadata }).from(clients).where(eq(clients.id, clientId)).limit(1);
9492
+ if (!client) return;
9493
+ if (!clientCapabilitiesReported(client.metadata)) return;
9494
+ 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.`);
9495
+ }
8423
9496
  async function resolveAgentClient(db, data) {
8424
9497
  if (data.type === "human") {
8425
9498
  if (data.clientId) throw new BadRequestError("Human agents cannot be pinned to a client");
@@ -8452,9 +9525,10 @@ async function resolveFallbackManagerId(db, orgId) {
8452
9525
  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
9526
  return row.id;
8454
9527
  }
8455
- async function createAgent(db, data) {
9528
+ async function createAgent(db, data, options = {}) {
8456
9529
  const uuid = uuidv7();
8457
9530
  const name = data.name ?? null;
9531
+ const runtimeProvider = data.runtimeProvider ?? "claude-code";
8458
9532
  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
9533
  if (name && isReservedAgentName$1(name)) throw new BadRequestError(`Agent name "${name}" is reserved — pick a different one.`);
8460
9534
  const inboxId = `inbox_${uuid}`;
@@ -8480,6 +9554,7 @@ async function createAgent(db, data) {
8480
9554
  managerId,
8481
9555
  type: data.type
8482
9556
  });
9557
+ await ensureClientSupportsRuntimeProvider(db, clientId, runtimeProvider, { force: options.force });
8483
9558
  const [org] = await db.select({ maxAgents: organizations.maxAgents }).from(organizations).where(eq(organizations.id, orgId)).limit(1);
8484
9559
  if (org && org.maxAgents > 0) {
8485
9560
  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 +9573,14 @@ async function createAgent(db, data) {
8498
9573
  visibility: data.visibility ?? defaultVisibility(data.type),
8499
9574
  metadata: data.metadata ?? {},
8500
9575
  managerId,
8501
- clientId
9576
+ clientId,
9577
+ runtimeProvider
8502
9578
  }).returning();
8503
9579
  if (!agent) throw new Error("Unexpected: INSERT RETURNING produced no row");
8504
9580
  await db.insert(agentConfigs).values({
8505
9581
  agentId: agent.uuid,
8506
9582
  version: 1,
8507
- payload: DEFAULT_AGENT_RUNTIME_CONFIG_PAYLOAD,
9583
+ payload: defaultRuntimeConfigPayload(runtimeProvider),
8508
9584
  updatedBy: "system"
8509
9585
  }).onConflictDoNothing();
8510
9586
  return agent;
@@ -8558,6 +9634,7 @@ async function listAgentsForAdmin(db, scope, limit, cursor) {
8558
9634
  metadata: agents.metadata,
8559
9635
  managerId: agents.managerId,
8560
9636
  clientId: agents.clientId,
9637
+ runtimeProvider: agents.runtimeProvider,
8561
9638
  createdAt: agents.createdAt,
8562
9639
  updatedAt: agents.updatedAt,
8563
9640
  presenceStatus: agentPresence.status,
@@ -8595,6 +9672,7 @@ async function listAgentsForMember(db, scope, limit, cursor, type) {
8595
9672
  metadata: agents.metadata,
8596
9673
  managerId: agents.managerId,
8597
9674
  clientId: agents.clientId,
9675
+ runtimeProvider: agents.runtimeProvider,
8598
9676
  createdAt: agents.createdAt,
8599
9677
  updatedAt: agents.updatedAt,
8600
9678
  presenceStatus: agentPresence.status,
@@ -8614,7 +9692,7 @@ async function updateAgent(db, uuid, data) {
8614
9692
  const agent = await getAgent(db, uuid);
8615
9693
  if (data.clientId !== void 0) {
8616
9694
  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");
9695
+ 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
9696
  }
8619
9697
  const updates = { updatedAt: /* @__PURE__ */ new Date() };
8620
9698
  if (data.type !== void 0) updates.type = data.type;
@@ -8645,6 +9723,39 @@ async function updateAgent(db, uuid, data) {
8645
9723
  return updated;
8646
9724
  }
8647
9725
  /**
9726
+ * Atomically re-bind an agent to a new client and/or runtime provider.
9727
+ *
9728
+ * Validations: agent must exist and not be human; new client must belong to
9729
+ * the same owner (manager.userId) and same organization; client must report
9730
+ * the requested runtime provider in its capabilities (skipped under `force`).
9731
+ *
9732
+ * Intended caller: PATCH /admin/agents/:agentId/rebind. The Web "Re-bind"
9733
+ * dialog routes both same-client runtime-only switches and cross-client
9734
+ * moves through this single entry.
9735
+ *
9736
+ * NOTE: active sessions on the previous client are not auto-suspended in P1.
9737
+ * P3 will wire in cross-service coordination (inbox + presence + session)
9738
+ * so the destination client can resume cleanly.
9739
+ */
9740
+ async function rebindAgent(db, uuid, data) {
9741
+ const agent = await getAgent(db, uuid);
9742
+ if (agent.type === "human") throw new BadRequestError("Human agents have no runtime — they cannot be re-bound to a client.");
9743
+ const newClientId = await resolveAgentClient(db, {
9744
+ clientId: data.clientId,
9745
+ managerId: agent.managerId,
9746
+ type: agent.type
9747
+ });
9748
+ if (newClientId === null) throw new BadRequestError("Rebind requires a non-null clientId.");
9749
+ await ensureClientSupportsRuntimeProvider(db, newClientId, data.runtimeProvider, { force: data.force });
9750
+ const [updated] = await db.update(agents).set({
9751
+ clientId: newClientId,
9752
+ runtimeProvider: data.runtimeProvider,
9753
+ updatedAt: /* @__PURE__ */ new Date()
9754
+ }).where(eq(agents.uuid, uuid)).returning();
9755
+ if (!updated) throw new Error("Unexpected: UPDATE RETURNING produced no row");
9756
+ return updated;
9757
+ }
9758
+ /**
8648
9759
  * Reactivate a suspended agent.
8649
9760
  */
8650
9761
  async function reactivateAgent(db, uuid) {
@@ -9224,10 +10335,45 @@ async function listActiveAgentsPinnedToClient(db, clientId) {
9224
10335
  uuid: agents.uuid,
9225
10336
  name: agents.name,
9226
10337
  displayName: agents.displayName,
9227
- type: agents.type
10338
+ type: agents.type,
10339
+ runtimeProvider: agents.runtimeProvider
9228
10340
  }).from(agents).where(and(eq(agents.clientId, clientId), ne(agents.status, "deleted")));
9229
10341
  }
9230
10342
  /**
10343
+ * Member-scoped: every active agent pinned to a client owned by this user
10344
+ * within the given organization. Used by client startup to reconcile its
10345
+ * local YAML against the authoritative `agents.runtime_provider`.
10346
+ */
10347
+ async function listMyPinnedAgents(db, scope) {
10348
+ return (await db.select({
10349
+ agentId: agents.uuid,
10350
+ clientId: agents.clientId,
10351
+ runtimeProvider: agents.runtimeProvider
10352
+ }).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) => ({
10353
+ agentId: r.agentId,
10354
+ clientId: r.clientId,
10355
+ runtimeProvider: r.runtimeProvider
10356
+ }));
10357
+ }
10358
+ /**
10359
+ * Replace this client's capabilities snapshot. Capabilities live under
10360
+ * `clients.metadata.capabilities` (Option C — no dedicated column); other
10361
+ * `metadata` subkeys are preserved on merge.
10362
+ *
10363
+ * Caller is expected to have already passed `assertClientOwner`.
10364
+ */
10365
+ async function updateClientCapabilities(db, clientId, capabilities) {
10366
+ const parsed = clientCapabilitiesSchema$1.safeParse(capabilities);
10367
+ if (!parsed.success) throw new BadRequestError(`Invalid capabilities payload: ${parsed.error.message}`);
10368
+ const [client] = await db.select({ metadata: clients.metadata }).from(clients).where(eq(clients.id, clientId)).limit(1);
10369
+ if (!client) throw new NotFoundError(`Client "${clientId}" not found`);
10370
+ const merged = {
10371
+ ...client.metadata ?? {},
10372
+ capabilities: parsed.data
10373
+ };
10374
+ await db.update(clients).set({ metadata: merged }).where(eq(clients.id, clientId));
10375
+ }
10376
+ /**
9231
10377
  * Scope-aware client listing.
9232
10378
  *
9233
10379
  * - member: rows where `user_id = scope.userId` AND `organization_id = scope.organizationId`
@@ -9422,24 +10568,144 @@ function forceDisconnectClient(clientId) {
9422
10568
  clientConnections.delete(clientId);
9423
10569
  return agentIds;
9424
10570
  }
9425
- /** Delivery queue (envelope). One entry per recipient created during message fan-out. Uses SKIP LOCKED for concurrent-safe consumption. */
9426
- const inboxEntries = pgTable("inbox_entries", {
9427
- id: bigserial("id", { mode: "number" }).primaryKey(),
9428
- inboxId: text("inbox_id").notNull(),
9429
- messageId: text("message_id").notNull().references(() => messages.id),
9430
- chatId: text("chat_id"),
9431
- status: text("status").notNull().default("pending"),
9432
- notify: boolean("notify").notNull().default(true),
9433
- retryCount: integer("retry_count").notNull().default(0),
9434
- createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
9435
- deliveredAt: timestamp("delivered_at", { withTimezone: true }),
9436
- ackedAt: timestamp("acked_at", { withTimezone: true })
9437
- }, (table) => [
9438
- unique("uq_inbox_delivery").on(table.inboxId, table.messageId, table.chatId),
9439
- index("idx_inbox_pending").on(table.inboxId, table.createdAt),
9440
- index("idx_inbox_pending_notify").on(table.inboxId, table.createdAt).where(sql`status = 'pending' AND notify = true`),
9441
- index("idx_inbox_chat_silent").on(table.inboxId, table.chatId, table.notify, table.status)
9442
- ]);
10571
+ /** Delivery queue (envelope). One entry per recipient created during message fan-out. Uses SKIP LOCKED for concurrent-safe consumption. */
10572
+ const inboxEntries = pgTable("inbox_entries", {
10573
+ id: bigserial("id", { mode: "number" }).primaryKey(),
10574
+ inboxId: text("inbox_id").notNull(),
10575
+ messageId: text("message_id").notNull().references(() => messages.id),
10576
+ chatId: text("chat_id"),
10577
+ status: text("status").notNull().default("pending"),
10578
+ notify: boolean("notify").notNull().default(true),
10579
+ retryCount: integer("retry_count").notNull().default(0),
10580
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
10581
+ deliveredAt: timestamp("delivered_at", { withTimezone: true }),
10582
+ ackedAt: timestamp("acked_at", { withTimezone: true })
10583
+ }, (table) => [
10584
+ unique("uq_inbox_delivery").on(table.inboxId, table.messageId, table.chatId),
10585
+ index("idx_inbox_pending").on(table.inboxId, table.createdAt),
10586
+ index("idx_inbox_pending_notify").on(table.inboxId, table.createdAt).where(sql`status = 'pending' AND notify = true`),
10587
+ index("idx_inbox_chat_silent").on(table.inboxId, table.chatId, table.notify, table.status)
10588
+ ]);
10589
+ /** Per-session state snapshot. One row per (agent, chat) pair, upserted on each session:state message. */
10590
+ const agentChatSessions = pgTable("agent_chat_sessions", {
10591
+ agentId: text("agent_id").notNull().references(() => agents.uuid, { onDelete: "cascade" }),
10592
+ chatId: text("chat_id").notNull().references(() => chats.id, { onDelete: "cascade" }),
10593
+ state: text("state").notNull(),
10594
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
10595
+ }, (table) => [primaryKey({ columns: [table.agentId, table.chatId] })]);
10596
+ /**
10597
+ * Upsert session state + refresh presence aggregates + NOTIFY.
10598
+ *
10599
+ * `agent_chat_sessions.(agent_id, chat_id)` is a single-row "current session
10600
+ * state" cache, not a session history log. A new runtime session starting on
10601
+ * the same (agent, chat) pair MUST overwrite whatever ended before — including
10602
+ * an `evicted` row left by a previous terminate. The previous "revival
10603
+ * defense" conflated two concerns: "this runtime session ended" (which is
10604
+ * what `evicted` actually means) and "this chat is permanently archived for
10605
+ * this agent" (a chat-level decision that should live on `chats`, not here).
10606
+ * See proposals/hub-agent-messaging-reply-and-mentions §M2-session-lifecycle.
10607
+ *
10608
+ * Presence row contract: this function tolerates a missing `agent_presence`
10609
+ * row by using `INSERT ... ON CONFLICT DO UPDATE`. The predictive-write path
10610
+ * (sendMessage on first message) may target an agent whose client has never
10611
+ * bound, so a prior `update agent_presence ... where agentId` would silently
10612
+ * drop the activeSessions/totalSessions refresh. See PR #198 review §2.
10613
+ */
10614
+ async function upsertSessionState(db, agentId, chatId, state, organizationId, notifier, options) {
10615
+ const now = /* @__PURE__ */ new Date();
10616
+ let wrote = false;
10617
+ await db.transaction(async (tx) => {
10618
+ await tx.insert(agentChatSessions).values({
10619
+ agentId,
10620
+ chatId,
10621
+ state,
10622
+ updatedAt: now
10623
+ }).onConflictDoUpdate({
10624
+ target: [agentChatSessions.agentId, agentChatSessions.chatId],
10625
+ set: {
10626
+ state,
10627
+ updatedAt: now
10628
+ },
10629
+ setWhere: ne(agentChatSessions.state, state)
10630
+ });
10631
+ const [counts] = await tx.select({
10632
+ active: sql`count(*) FILTER (WHERE ${agentChatSessions.state} = 'active')::int`,
10633
+ total: sql`count(*) FILTER (WHERE ${agentChatSessions.state} != 'evicted')::int`
10634
+ }).from(agentChatSessions).where(eq(agentChatSessions.agentId, agentId));
10635
+ const activeSessions = counts?.active ?? 0;
10636
+ const totalSessions = counts?.total ?? 0;
10637
+ const presenceSet = options?.touchPresenceLastSeen ?? true ? {
10638
+ activeSessions,
10639
+ totalSessions,
10640
+ lastSeenAt: now
10641
+ } : {
10642
+ activeSessions,
10643
+ totalSessions
10644
+ };
10645
+ await tx.insert(agentPresence).values({
10646
+ agentId,
10647
+ activeSessions,
10648
+ totalSessions
10649
+ }).onConflictDoUpdate({
10650
+ target: [agentPresence.agentId],
10651
+ set: presenceSet
10652
+ });
10653
+ wrote = true;
10654
+ });
10655
+ if (wrote && notifier) notifier.notifySessionStateChange(agentId, chatId, state, organizationId).catch(() => {});
10656
+ }
10657
+ async function resetActivity(db, agentId) {
10658
+ const now = /* @__PURE__ */ new Date();
10659
+ await db.update(agentPresence).set({
10660
+ runtimeState: "idle",
10661
+ runtimeUpdatedAt: now
10662
+ }).where(eq(agentPresence.agentId, agentId));
10663
+ }
10664
+ async function getActivityOverview(db) {
10665
+ const [agentCounts] = await db.select({
10666
+ total: sql`count(*)::int`,
10667
+ running: sql`count(*) FILTER (WHERE ${agentPresence.runtimeState} IS NOT NULL)::int`,
10668
+ idle: sql`count(*) FILTER (WHERE ${agentPresence.runtimeState} = 'idle')::int`,
10669
+ working: sql`count(*) FILTER (WHERE ${agentPresence.runtimeState} = 'working')::int`,
10670
+ blocked: sql`count(*) FILTER (WHERE ${agentPresence.runtimeState} = 'blocked')::int`,
10671
+ error: sql`count(*) FILTER (WHERE ${agentPresence.runtimeState} = 'error')::int`
10672
+ }).from(agentPresence);
10673
+ const [clientCounts] = await db.select({ count: sql`count(*)::int` }).from(clients).where(eq(clients.status, "connected"));
10674
+ return {
10675
+ total: agentCounts?.total ?? 0,
10676
+ running: agentCounts?.running ?? 0,
10677
+ byState: {
10678
+ idle: agentCounts?.idle ?? 0,
10679
+ working: agentCounts?.working ?? 0,
10680
+ blocked: agentCounts?.blocked ?? 0,
10681
+ error: agentCounts?.error ?? 0
10682
+ },
10683
+ clients: clientCounts?.count ?? 0
10684
+ };
10685
+ }
10686
+ /**
10687
+ * List agents with active runtime state.
10688
+ * When scope is provided, filters to agents visible to the member.
10689
+ */
10690
+ async function listAgentsWithRuntime(db, scope) {
10691
+ if (!scope) return db.select().from(agentPresence).where(isNotNull(agentPresence.runtimeState));
10692
+ return db.select({
10693
+ agentId: agentPresence.agentId,
10694
+ status: agentPresence.status,
10695
+ instanceId: agentPresence.instanceId,
10696
+ connectedAt: agentPresence.connectedAt,
10697
+ lastSeenAt: agentPresence.lastSeenAt,
10698
+ clientId: agentPresence.clientId,
10699
+ runtimeType: agentPresence.runtimeType,
10700
+ runtimeVersion: agentPresence.runtimeVersion,
10701
+ runtimeState: agentPresence.runtimeState,
10702
+ activeSessions: agentPresence.activeSessions,
10703
+ totalSessions: agentPresence.totalSessions,
10704
+ runtimeUpdatedAt: agentPresence.runtimeUpdatedAt,
10705
+ type: agents.type
10706
+ }).from(agentPresence).innerJoin(agents, eq(agentPresence.agentId, agents.uuid)).where(and(isNotNull(agentPresence.runtimeState), agentVisibilityCondition(scope)));
10707
+ }
10708
+ const log$4 = createLogger$1("message");
9443
10709
  async function sendMessage(db, chatId, senderId, data, options = {}) {
9444
10710
  return withSpan("inbox.enqueue", messageAttrs({
9445
10711
  chatId,
@@ -9448,17 +10714,24 @@ async function sendMessage(db, chatId, senderId, data, options = {}) {
9448
10714
  }), () => sendMessageInner(db, chatId, senderId, data, options));
9449
10715
  }
9450
10716
  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)]);
10717
+ const txResult = await db.transaction(async (tx) => {
10718
+ const [participants, [chatRow], [senderRow]] = await Promise.all([
10719
+ tx.select({
10720
+ agentId: chatParticipants.agentId,
10721
+ inboxId: agents.inboxId,
10722
+ mode: chatParticipants.mode,
10723
+ name: agents.name
10724
+ }).from(chatParticipants).innerJoin(agents, eq(chatParticipants.agentId, agents.uuid)).where(eq(chatParticipants.chatId, chatId)),
10725
+ tx.select({ type: chats.type }).from(chats).where(eq(chats.id, chatId)).limit(1),
10726
+ tx.select({
10727
+ inboxId: agents.inboxId,
10728
+ organizationId: agents.organizationId
10729
+ }).from(agents).where(eq(agents.uuid, senderId)).limit(1)
10730
+ ]);
9458
10731
  const chatType = chatRow?.type ?? null;
10732
+ if (!senderRow) throw new NotFoundError(`Sender agent "${senderId}" not found`);
9459
10733
  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");
10734
+ if (senderRow.inboxId !== data.replyToInbox) throw new BadRequestError("replyToInbox must reference the sender's own inbox");
9462
10735
  }
9463
10736
  const incomingMeta = data.metadata ?? {};
9464
10737
  const explicitMentionsRaw = incomingMeta.mentions;
@@ -9503,14 +10776,20 @@ async function sendMessageInner(db, chatId, senderId, data, options) {
9503
10776
  source: data.source ?? null
9504
10777
  }).returning();
9505
10778
  const mentionSet = new Set(mergedMentions);
9506
- const entries = participants.filter((p) => p.agentId !== senderId).map((p) => ({
10779
+ const fanout = participants.filter((p) => p.agentId !== senderId).map((p) => ({
10780
+ agentId: p.agentId,
9507
10781
  inboxId: p.inboxId,
9508
- messageId,
9509
- chatId,
9510
10782
  notify: p.mode !== "mention_only" || mentionSet.has(p.agentId)
9511
10783
  }));
9512
- if (entries.length > 0) await tx.insert(inboxEntries).values(entries);
9513
- const recipients = entries.filter((e) => e.notify).map((e) => e.inboxId);
10784
+ if (fanout.length > 0) await tx.insert(inboxEntries).values(fanout.map((f) => ({
10785
+ inboxId: f.inboxId,
10786
+ messageId,
10787
+ chatId,
10788
+ notify: f.notify
10789
+ })));
10790
+ const notified = fanout.filter((f) => f.notify);
10791
+ const recipients = notified.map((f) => f.inboxId);
10792
+ const recipientAgentIds = notified.map((f) => f.agentId);
9514
10793
  if (data.inReplyTo) {
9515
10794
  const [original] = await tx.select({
9516
10795
  replyToInbox: messages.replyToInbox,
@@ -9529,9 +10808,24 @@ async function sendMessageInner(db, chatId, senderId, data, options) {
9529
10808
  if (!msg) throw new Error("Unexpected: INSERT RETURNING produced no row");
9530
10809
  return {
9531
10810
  message: msg,
9532
- recipients
10811
+ recipients,
10812
+ recipientAgentIds,
10813
+ organizationId: senderRow.organizationId
9533
10814
  };
9534
10815
  });
10816
+ const settled = await Promise.allSettled(txResult.recipientAgentIds.map((agentId) => upsertSessionState(db, agentId, chatId, "active", txResult.organizationId, void 0, { touchPresenceLastSeen: false })));
10817
+ for (let i = 0; i < settled.length; i++) {
10818
+ const r = settled[i];
10819
+ if (r?.status === "rejected") log$4.error({
10820
+ err: r.reason,
10821
+ chatId,
10822
+ agentId: txResult.recipientAgentIds[i]
10823
+ }, "predictive session activation failed");
10824
+ }
10825
+ return {
10826
+ message: txResult.message,
10827
+ recipients: txResult.recipients
10828
+ };
9535
10829
  }
9536
10830
  async function sendToAgent(db, senderUuid, targetName, data) {
9537
10831
  const [sender] = await db.select({
@@ -9605,27 +10899,31 @@ function createNotifier(listenClient) {
9605
10899
  const messageId = payload.slice(sepIdx + 1);
9606
10900
  const sockets = subscriptions.get(inboxId);
9607
10901
  if (!sockets) return;
9608
- const data = JSON.stringify({
10902
+ const doorbellFrame = JSON.stringify({
9609
10903
  type: "new_message",
9610
10904
  inboxId,
9611
10905
  messageId
9612
10906
  });
9613
- for (const ws of sockets) if (ws.readyState === ws.OPEN) ws.send(data);
10907
+ for (const [ws, pushHandler] of sockets) {
10908
+ if (ws.readyState !== ws.OPEN) continue;
10909
+ if (pushHandler) Promise.resolve(pushHandler(messageId)).catch(() => {});
10910
+ else ws.send(doorbellFrame);
10911
+ }
9614
10912
  }
9615
10913
  return {
9616
- subscribe(inboxId, ws) {
9617
- let set = subscriptions.get(inboxId);
9618
- if (!set) {
9619
- set = /* @__PURE__ */ new Set();
9620
- subscriptions.set(inboxId, set);
10914
+ subscribe(inboxId, ws, pushHandler) {
10915
+ let map = subscriptions.get(inboxId);
10916
+ if (!map) {
10917
+ map = /* @__PURE__ */ new Map();
10918
+ subscriptions.set(inboxId, map);
9621
10919
  }
9622
- set.add(ws);
10920
+ map.set(ws, pushHandler ?? null);
9623
10921
  },
9624
10922
  unsubscribe(inboxId, ws) {
9625
- const set = subscriptions.get(inboxId);
9626
- if (set) {
9627
- set.delete(ws);
9628
- if (set.size === 0) subscriptions.delete(inboxId);
10923
+ const map = subscriptions.get(inboxId);
10924
+ if (map) {
10925
+ map.delete(ws);
10926
+ if (map.size === 0) subscriptions.delete(inboxId);
9629
10927
  }
9630
10928
  },
9631
10929
  async notify(inboxId, messageId) {
@@ -9649,11 +10947,11 @@ function createNotifier(listenClient) {
9649
10947
  } catch {}
9650
10948
  },
9651
10949
  async pushFrameToInbox(inboxId, frame) {
9652
- const sockets = subscriptions.get(inboxId);
9653
- if (!sockets) return 0;
10950
+ const map = subscriptions.get(inboxId);
10951
+ if (!map) return 0;
9654
10952
  let queued = 0;
9655
10953
  const pending = [];
9656
- for (const ws of sockets) {
10954
+ for (const ws of map.keys()) {
9657
10955
  if (ws.readyState !== ws.OPEN) continue;
9658
10956
  pending.push(new Promise((resolve) => {
9659
10957
  ws.send(frame, (err) => {
@@ -9758,7 +11056,8 @@ async function adminAgentRoutes(app) {
9758
11056
  agentId: agent.uuid,
9759
11057
  name: agent.name,
9760
11058
  displayName: agent.displayName,
9761
- agentType: agent.type
11059
+ agentType: agent.type,
11060
+ runtimeProvider: agent.runtimeProvider
9762
11061
  });
9763
11062
  if (!parsed.success) {
9764
11063
  app.log.warn({
@@ -9861,6 +11160,23 @@ async function adminAgentRoutes(app) {
9861
11160
  updatedAt: agent.updatedAt.toISOString()
9862
11161
  };
9863
11162
  });
11163
+ /**
11164
+ * Rebind an agent to a new client and/or runtime provider. Re-runs owner /
11165
+ * org / capability checks atomically. Capability mismatch can be overridden
11166
+ * with `force: true` (e.g. client offline, capabilities stale).
11167
+ */
11168
+ app.patch("/:uuid/rebind", async (request) => {
11169
+ const scope = memberScope(request);
11170
+ await assertCanManage(app.db, scope, request.params.uuid);
11171
+ const body = rebindAgentSchema.parse(request.body);
11172
+ const agent = await rebindAgent(app.db, request.params.uuid, body);
11173
+ notifyClientAgentPinned(agent);
11174
+ return {
11175
+ ...agent,
11176
+ createdAt: agent.createdAt.toISOString(),
11177
+ updatedAt: agent.updatedAt.toISOString()
11178
+ };
11179
+ });
9864
11180
  app.get("/:uuid", async (request) => {
9865
11181
  const scope = memberScope(request);
9866
11182
  await assertAgentVisible(app.db, scope, request.params.uuid);
@@ -10315,107 +11631,6 @@ async function adminChatRoutes(app) {
10315
11631
  });
10316
11632
  });
10317
11633
  }
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
11634
  /** Serialize a Date to ISO string, or null. */
10420
11635
  function serializeDate(d) {
10421
11636
  return d ? d.toISOString() : null;
@@ -10439,11 +11654,27 @@ async function adminClientRoutes(app) {
10439
11654
  lastSeenAt: c.lastSeenAt.toISOString()
10440
11655
  }));
10441
11656
  });
11657
+ app.get("/me/agents", async (request) => {
11658
+ const scope = memberScope(request);
11659
+ return await listMyPinnedAgents(app.db, {
11660
+ userId: scope.userId,
11661
+ organizationId: scope.organizationId
11662
+ });
11663
+ });
11664
+ app.patch("/:clientId/capabilities", async (request, reply) => {
11665
+ const scope = memberScope(request);
11666
+ await assertClientOwner(app.db, request.params.clientId, scope);
11667
+ const body = updateClientCapabilitiesSchema.parse(request.body);
11668
+ await updateClientCapabilities(app.db, request.params.clientId, body.capabilities);
11669
+ return reply.status(204).send();
11670
+ });
10442
11671
  app.get("/:clientId", async (request) => {
10443
11672
  const scope = memberScope(request);
10444
11673
  await assertClientOwner(app.db, request.params.clientId, scope);
10445
11674
  const client = await getClient(app.db, request.params.clientId);
10446
11675
  if (!client) throw new Error("unreachable: client missing after owner check");
11676
+ const metadata = client.metadata ?? {};
11677
+ const capabilities = metadata.capabilities && typeof metadata.capabilities === "object" ? metadata.capabilities : {};
10447
11678
  return {
10448
11679
  id: client.id,
10449
11680
  userId: client.userId,
@@ -10452,7 +11683,8 @@ async function adminClientRoutes(app) {
10452
11683
  hostname: client.hostname,
10453
11684
  os: client.os,
10454
11685
  connectedAt: serializeDate(client.connectedAt),
10455
- lastSeenAt: client.lastSeenAt.toISOString()
11686
+ lastSeenAt: client.lastSeenAt.toISOString(),
11687
+ capabilities
10456
11688
  };
10457
11689
  });
10458
11690
  app.post("/:clientId/disconnect", async (request) => {
@@ -12159,8 +13391,8 @@ async function pollInbox(db, inboxId, limit) {
12159
13391
  }, () => pollInboxInner(db, inboxId, limit));
12160
13392
  }
12161
13393
  async function pollInboxInner(db, inboxId, limit) {
12162
- return await db.transaction(async (tx) => {
12163
- const claimed = await tx.execute(sql`
13394
+ return db.transaction(async (tx) => {
13395
+ return bundleDeliveryWithSilentContext(tx, inboxId, await tx.execute(sql`
12164
13396
  UPDATE inbox_entries
12165
13397
  SET status = 'delivered', delivered_at = NOW()
12166
13398
  WHERE id IN (
@@ -12171,53 +13403,132 @@ async function pollInboxInner(db, inboxId, limit) {
12171
13403
  FOR UPDATE SKIP LOCKED
12172
13404
  )
12173
13405
  RETURNING *
12174
- `);
12175
- if (claimed.length === 0) return [];
12176
- claimed.sort((a, b) => a.created_at.localeCompare(b.created_at));
12177
- const precedingByEntryId = await collectPrecedingContext(tx, inboxId, claimed);
12178
- const messageIds = claimed.map((e) => e.message_id);
12179
- const msgs = await tx.select().from(messages).where(inArray(messages.id, messageIds));
12180
- const msgMap = new Map(msgs.map((m) => [m.id, m]));
12181
- const payloads = await buildClientMessagePayloadsForInbox(tx, inboxId, claimed.map((entry) => {
12182
- const msg = msgMap.get(entry.message_id);
12183
- if (!msg) throw new Error(`Unexpected: message ${entry.message_id} not found`);
12184
- return {
12185
- entryChatId: entry.chat_id,
12186
- precedingMessages: precedingByEntryId.get(entry.id) ?? [],
12187
- message: {
12188
- id: msg.id,
12189
- chatId: msg.chatId,
12190
- senderId: msg.senderId,
12191
- format: msg.format,
12192
- content: msg.content,
12193
- metadata: msg.metadata,
12194
- replyToInbox: msg.replyToInbox,
12195
- replyToChat: msg.replyToChat,
12196
- inReplyTo: msg.inReplyTo,
12197
- source: msg.source,
12198
- createdAt: msg.createdAt.toISOString()
12199
- }
12200
- };
12201
- }));
12202
- return claimed.map((entry, idx) => {
12203
- const payload = payloads[idx];
12204
- if (!payload) throw new Error(`Unexpected: payload for entry ${entry.id} not built`);
12205
- return {
12206
- id: entry.id,
12207
- inboxId: entry.inbox_id,
12208
- messageId: entry.message_id,
12209
- chatId: entry.chat_id,
12210
- status: entry.status,
12211
- retryCount: entry.retry_count,
12212
- createdAt: entry.created_at,
12213
- deliveredAt: entry.delivered_at ?? null,
12214
- ackedAt: entry.acked_at ?? null,
12215
- message: payload
12216
- };
12217
- });
13406
+ `));
13407
+ });
13408
+ }
13409
+ /**
13410
+ * Shared payload assembler for already-claimed `inbox_entries` rows.
13411
+ *
13412
+ * Both the HTTP poll path (`pollInbox`) and the WS push path
13413
+ * (`claimAndBuildForPush`) call this with rows they have just `UPDATE`d to
13414
+ * `status='delivered'`. Keeping the silent-context bundling in one place is
13415
+ * the only way to keep the two paths from drifting (proposal
13416
+ * hub-inbox-ws-data-plane §3.2 risk #1).
13417
+ *
13418
+ * Steps:
13419
+ * 1. Sort by `created_at` ASC (PG `RETURNING` does not guarantee order).
13420
+ * 2. For each trigger, collect silent context & bulk-ack stale silent rows.
13421
+ * 3. Fetch the trigger messages.
13422
+ * 4. Build wire payloads via the single dispatcher.
13423
+ *
13424
+ * Returns `[]` if `claimed` is empty.
13425
+ */
13426
+ async function bundleDeliveryWithSilentContext(tx, inboxId, claimed) {
13427
+ if (claimed.length === 0) return [];
13428
+ claimed.sort((a, b) => a.created_at.localeCompare(b.created_at));
13429
+ const precedingByEntryId = await collectPrecedingContext(tx, inboxId, claimed);
13430
+ const messageIds = claimed.map((e) => e.message_id);
13431
+ const msgs = await tx.select().from(messages).where(inArray(messages.id, messageIds));
13432
+ const msgMap = new Map(msgs.map((m) => [m.id, m]));
13433
+ const payloads = await buildClientMessagePayloadsForInbox(tx, inboxId, claimed.map((entry) => {
13434
+ const msg = msgMap.get(entry.message_id);
13435
+ if (!msg) throw new Error(`Unexpected: message ${entry.message_id} not found`);
13436
+ return {
13437
+ entryChatId: entry.chat_id,
13438
+ precedingMessages: precedingByEntryId.get(entry.id) ?? [],
13439
+ message: {
13440
+ id: msg.id,
13441
+ chatId: msg.chatId,
13442
+ senderId: msg.senderId,
13443
+ format: msg.format,
13444
+ content: msg.content,
13445
+ metadata: msg.metadata,
13446
+ replyToInbox: msg.replyToInbox,
13447
+ replyToChat: msg.replyToChat,
13448
+ inReplyTo: msg.inReplyTo,
13449
+ source: msg.source,
13450
+ createdAt: msg.createdAt.toISOString()
13451
+ }
13452
+ };
13453
+ }));
13454
+ return claimed.map((entry, idx) => {
13455
+ const payload = payloads[idx];
13456
+ if (!payload) throw new Error(`Unexpected: payload for entry ${entry.id} not built`);
13457
+ return {
13458
+ id: Number(entry.id),
13459
+ inboxId: entry.inbox_id,
13460
+ messageId: entry.message_id,
13461
+ chatId: entry.chat_id,
13462
+ status: entry.status,
13463
+ retryCount: entry.retry_count,
13464
+ createdAt: entry.created_at,
13465
+ deliveredAt: entry.delivered_at ?? null,
13466
+ ackedAt: entry.acked_at ?? null,
13467
+ message: payload
13468
+ };
12218
13469
  });
12219
13470
  }
12220
13471
  /**
13472
+ * Realistic upper bound on rows a single NOTIFY references. The unique
13473
+ * constraint `(inbox_id, message_id, chat_id)` caps a `(inbox, message)`
13474
+ * pair at one row per chatId; the only way to exceed 1 today is the replyTo
13475
+ * cross-chat path (`message.ts` writes a second row keyed by the original's
13476
+ * `replyToChat`). 8 leaves headroom for any future fan-out variant without
13477
+ * requiring a schema change here.
13478
+ */
13479
+ const PUSH_CLAIM_BATCH_LIMIT = 8;
13480
+ /**
13481
+ * WS-push path: atomically claim every pending entry the just-fired
13482
+ * `NOTIFY (inboxId:messageId)` references and assemble their wire payloads.
13483
+ *
13484
+ * Returns `[]` if no row matches — benign race with HTTP poll or another
13485
+ * server instance that already claimed the entry. NOTIFY is fire-and-forget
13486
+ * (proposal §3.2).
13487
+ *
13488
+ * Why an array, not a single row: `sendMessage` can write **two** rows for
13489
+ * the same `(inbox, messageId)` pair when the recipient is both a chat
13490
+ * participant and the `replyToInbox` of an earlier message — the unique key
13491
+ * is `(inbox_id, message_id, chat_id)`, so the rows differ by chatId. The
13492
+ * old `LIMIT 1` shape would only push the first; the second sat `pending`
13493
+ * until reconnect. Aligning with `pollInboxInner`'s `LIMIT N` shape closes
13494
+ * that gap and keeps push/poll behaviour interchangeable.
13495
+ */
13496
+ async function claimAndBuildForPush(db, inboxId, messageId) {
13497
+ return withSpan("inbox.deliver.push", {
13498
+ "inbox.id": inboxId,
13499
+ "message.id": messageId
13500
+ }, () => db.transaction(async (tx) => {
13501
+ return bundleDeliveryWithSilentContext(tx, inboxId, await tx.execute(sql`
13502
+ UPDATE inbox_entries
13503
+ SET status = 'delivered', delivered_at = NOW()
13504
+ WHERE id IN (
13505
+ SELECT id FROM inbox_entries
13506
+ WHERE inbox_id = ${inboxId}
13507
+ AND message_id = ${messageId}
13508
+ AND status = 'pending'
13509
+ AND notify = true
13510
+ ORDER BY created_at
13511
+ LIMIT ${PUSH_CLAIM_BATCH_LIMIT}
13512
+ FOR UPDATE SKIP LOCKED
13513
+ )
13514
+ RETURNING *
13515
+ `));
13516
+ }));
13517
+ }
13518
+ /**
13519
+ * WS-push backlog path: on agent rebind (or once an in-flight slot frees up
13520
+ * after an ack), drain up to `limit` pending `notify=true` entries oldest-
13521
+ * first and assemble wire payloads. Identical claim shape to the HTTP poll
13522
+ * path — they are intentionally interchangeable so a hot-path bug fixed in
13523
+ * one shows up in the other (proposal §3.3 / §3.5).
13524
+ */
13525
+ async function claimBacklogForPush(db, inboxId, limit) {
13526
+ return withSpan("inbox.deliver.backlog", {
13527
+ "inbox.id": inboxId,
13528
+ "inbox.backlog.limit": limit
13529
+ }, () => pollInboxInner(db, inboxId, limit));
13530
+ }
13531
+ /**
12221
13532
  * Per claimed trigger: SELECT silent (notify=false) pending rows in the same
12222
13533
  * chat that occurred between the previous trigger in this batch (or beginning
12223
13534
  * of time) and this trigger, capped by `PRECEDING_CONTEXT_MAX_ENTRIES` and
@@ -12293,6 +13604,26 @@ async function ackEntry$2(db, entryId, inboxId) {
12293
13604
  return entry;
12294
13605
  });
12295
13606
  }
13607
+ /**
13608
+ * Ack a delivered entry from the WS data plane, scoped to the inboxes the
13609
+ * connected socket has bound. Returns the acked row on success, `null` if no
13610
+ * row matches — a benign outcome the caller should ignore (the entry may
13611
+ * have already been acked, timed out, or never belonged to this socket).
13612
+ *
13613
+ * Distinct from {@link ackEntry} so the WS path can ack without trusting an
13614
+ * `inboxId` from the wire — only entries whose `inboxId` is in `inboxIds`
13615
+ * are eligible. Empty `inboxIds` short-circuits to `null`.
13616
+ */
13617
+ async function ackEntryByIdForBoundAgents(db, entryId, inboxIds) {
13618
+ if (inboxIds.length === 0) return null;
13619
+ return withSpan("inbox.ack.ws", { [FIRST_TREE_HUB_ATTR.INBOX_ENTRY_ID]: String(entryId) }, async () => {
13620
+ const [entry] = await db.update(inboxEntries).set({
13621
+ status: "acked",
13622
+ ackedAt: /* @__PURE__ */ new Date()
13623
+ }).where(and(eq(inboxEntries.id, entryId), inArray(inboxEntries.inboxId, inboxIds), eq(inboxEntries.status, "delivered"))).returning();
13624
+ return entry ?? null;
13625
+ });
13626
+ }
12296
13627
  async function renewEntry(db, entryId, inboxId) {
12297
13628
  const [entry] = await db.update(inboxEntries).set({ deliveredAt: /* @__PURE__ */ new Date() }).where(and(eq(inboxEntries.id, entryId), eq(inboxEntries.inboxId, inboxId), eq(inboxEntries.status, "delivered"))).returning();
12298
13629
  if (!entry) throw new NotFoundError("Inbox entry not found or not in delivered status");
@@ -12541,6 +13872,27 @@ async function agentTaskRoutes(app) {
12541
13872
  return getTaskHealth(app.db, request.params.taskId, identity.organizationId);
12542
13873
  });
12543
13874
  }
13875
+ /**
13876
+ * Default per-agent in-flight cap when `server.inbox.maxInFlightPerAgent` is
13877
+ * unset. Mirrors the schema default so a hub running without an explicit
13878
+ * `inbox` block still gets reasonable backpressure once `wsDataPlane` is
13879
+ * flipped on. See proposal hub-inbox-ws-data-plane §3.5.
13880
+ */
13881
+ const DEFAULT_INBOX_MAX_IN_FLIGHT_PER_AGENT = 32;
13882
+ /**
13883
+ * Hard cap on entries scanned in a single backlog drain so a recovering
13884
+ * client doesn't trigger an arbitrarily large transaction or burst of
13885
+ * frames. Anything beyond this stays `pending` and gets picked up by
13886
+ * subsequent post-ack drains. Same constant covers both the agent:bound
13887
+ * recovery path and the post-ack top-up.
13888
+ *
13889
+ * Lower than proposal §3.3's 500 on purpose: the actual limit per drain is
13890
+ * `min(remainingInFlightBudget, INBOX_BACKLOG_BATCH_LIMIT)`, so with a
13891
+ * default cap of 32 the drain SQL never asks for more than ~32 anyway.
13892
+ * Subsequent NOTIFYs and post-ack top-ups continue draining without a
13893
+ * single-transaction megabatch.
13894
+ */
13895
+ const INBOX_BACKLOG_BATCH_LIMIT = 50;
12544
13896
  const wsMessageSchema = z.object({
12545
13897
  type: z.string(),
12546
13898
  agentId: z.string().optional(),
@@ -12585,6 +13937,7 @@ function sendRejected(socket, ref, reason) {
12585
13937
  function clientWsRoutes(notifier, instanceId) {
12586
13938
  return async (app) => {
12587
13939
  const jwtSecretBytes = new TextEncoder().encode(app.config.secrets.jwtSecret);
13940
+ const inboxMaxInFlightPerAgent = app.config.inbox?.maxInFlightPerAgent ?? DEFAULT_INBOX_MAX_IN_FLIGHT_PER_AGENT;
12588
13941
  app.get("/client", {
12589
13942
  websocket: true,
12590
13943
  config: { otel: false }
@@ -12594,6 +13947,157 @@ function clientWsRoutes(notifier, instanceId) {
12594
13947
  let clientId = null;
12595
13948
  let authExpiryTimer = null;
12596
13949
  const boundAgents = /* @__PURE__ */ new Map();
13950
+ /**
13951
+ * Whether the connected client opted into the WS inbox data plane via
13952
+ * `client:register.wireCapabilities.wsInboxDeliver`. Set per-socket
13953
+ * because client SDKs are upgraded independently — an old client
13954
+ * connecting to a new server must keep receiving the legacy
13955
+ * `new_message` doorbell + HTTP poll path (proposal §3.6).
13956
+ */
13957
+ let clientWantsWsInboxDeliver = false;
13958
+ /**
13959
+ * Per-agent in-flight `inbox:deliver` counter for backpressure. Lives on
13960
+ * the socket — when the WS closes it goes with it; that's intentional,
13961
+ * because re-counting on a fresh connection would bias the cap against
13962
+ * a healthy reconnect (proposal §3.5).
13963
+ */
13964
+ const inboxInFlight = /* @__PURE__ */ new Map();
13965
+ function pushUseWsDataPlane() {
13966
+ return clientWantsWsInboxDeliver;
13967
+ }
13968
+ /**
13969
+ * Returns `false` when the socket has already moved out of `OPEN` —
13970
+ * the only failure mode the caller can observe synchronously.
13971
+ *
13972
+ * Note: `ws.send` is fire-and-forget; a buffered frame that fails
13973
+ * to actually flush (TCP slow-close, internal queue full) does NOT
13974
+ * surface here. That class of loss is recovered by the 300s timeout
13975
+ * reaper rolling the entry back to `pending` (§3.7). If you ever
13976
+ * need flush-level confirmation, switch to the `ws.send(frame, cb)`
13977
+ * callback form (see `notifier.ts pushFrameToInbox`).
13978
+ */
13979
+ function sendInboxDeliverFrame(entry) {
13980
+ if (socket.readyState !== socket.OPEN) return false;
13981
+ const frame = {
13982
+ type: "inbox:deliver",
13983
+ entryId: entry.id,
13984
+ inboxId: entry.inboxId,
13985
+ chatId: entry.chatId,
13986
+ message: entry.message
13987
+ };
13988
+ const validated = inboxDeliverFrameSchema$1.safeParse(frame);
13989
+ if (!validated.success) app.log.error({
13990
+ entryId: entry.id,
13991
+ inboxId: entry.inboxId,
13992
+ issues: validated.error.issues.map((i) => ({
13993
+ path: i.path.join("."),
13994
+ code: i.code,
13995
+ message: i.message
13996
+ }))
13997
+ }, "inbox:deliver frame failed self-validation — wire shape drift");
13998
+ socket.send(JSON.stringify(frame));
13999
+ return true;
14000
+ }
14001
+ /**
14002
+ * Build the per-socket push handler bound to a specific agent. Closes
14003
+ * over `agentId`, `inboxId`, the socket, and the in-flight counter.
14004
+ *
14005
+ * Backpressure: when the agent is at-cap we drop the NOTIFY (entry
14006
+ * stays `pending` server-side) and a debug log records the drop so
14007
+ * staging can correlate "messages slow" reports against cap hits.
14008
+ * The dropped row is replayed by `drainBacklogForAgent` once an ack
14009
+ * frees a slot, or by the next NOTIFY when we're back below cap (§3.5).
14010
+ *
14011
+ * Multi-row claims: a single `(inboxId, messageId)` pair can map to
14012
+ * more than one `inbox_entries` row (replyTo cross-chat case writes
14013
+ * a second row with a different chatId). We push every row claimed
14014
+ * by this NOTIFY in one go — see `claimAndBuildForPush`.
14015
+ *
14016
+ * The cap is intentionally **soft**: claim happens after the gate
14017
+ * check, so an N>1 claim can nudge in-flight slightly past
14018
+ * `inboxMaxInFlightPerAgent`. N is bounded by the
14019
+ * `(inbox_id, message_id, chat_id)` unique constraint (≤2 today),
14020
+ * so worst-case overshoot is small and the memory headroom in §3.5's
14021
+ * 64MB estimate covers it.
14022
+ */
14023
+ function makeInboxPushHandler(agentId, inboxId) {
14024
+ return async (messageId) => {
14025
+ const current = inboxInFlight.get(agentId) ?? 0;
14026
+ if (current >= inboxMaxInFlightPerAgent) {
14027
+ app.log.debug({
14028
+ agentId,
14029
+ inboxId,
14030
+ messageId,
14031
+ inFlightCount: current,
14032
+ cap: inboxMaxInFlightPerAgent
14033
+ }, "inbox push: at cap, dropping NOTIFY (will replay via post-ack drain)");
14034
+ return;
14035
+ }
14036
+ let entries;
14037
+ try {
14038
+ entries = await claimAndBuildForPush(app.db, inboxId, messageId);
14039
+ } catch (err) {
14040
+ app.log.error({
14041
+ err,
14042
+ inboxId,
14043
+ messageId,
14044
+ agentId
14045
+ }, "claimAndBuildForPush failed");
14046
+ return;
14047
+ }
14048
+ if (entries.length === 0) return;
14049
+ for (const entry of entries) {
14050
+ inboxInFlight.set(agentId, (inboxInFlight.get(agentId) ?? 0) + 1);
14051
+ if (!sendInboxDeliverFrame(entry)) {
14052
+ inboxInFlight.set(agentId, Math.max(0, (inboxInFlight.get(agentId) ?? 1) - 1));
14053
+ return;
14054
+ }
14055
+ }
14056
+ };
14057
+ }
14058
+ /**
14059
+ * Drain up to `INBOX_BACKLOG_BATCH_LIMIT` pending entries for an agent
14060
+ * over the current WS, capped by the remaining in-flight budget so a
14061
+ * full drain stays within the per-agent backpressure cap (§3.3, §3.5).
14062
+ *
14063
+ * Used in two places:
14064
+ * 1. Right after `agent:bound` — covers reconnects where NOTIFYs
14065
+ * were dropped while the socket was offline.
14066
+ * 2. Right after an `inbox:ack` — top up the in-flight slot just
14067
+ * freed, in case the previous NOTIFY was dropped at-cap.
14068
+ *
14069
+ * The cap is **soft**: this function reads `slotsFree` once before the
14070
+ * `claimBacklogForPush` round-trip, and a NOTIFY-driven push handler
14071
+ * may increment the counter concurrently. In the worst case in-flight
14072
+ * temporarily exceeds the cap by the number of concurrent pushes. With
14073
+ * the default cap of 32 and N ≤ 2 per push handler invocation, the
14074
+ * memory headroom in §3.5's 64MB estimate covers this.
14075
+ */
14076
+ async function drainBacklogForAgent(agentId, inboxId) {
14077
+ if (socket.readyState !== socket.OPEN) return;
14078
+ const slotsFree = inboxMaxInFlightPerAgent - (inboxInFlight.get(agentId) ?? 0);
14079
+ if (slotsFree <= 0) return;
14080
+ const limit = Math.min(slotsFree, INBOX_BACKLOG_BATCH_LIMIT);
14081
+ let entries;
14082
+ try {
14083
+ entries = await claimBacklogForPush(app.db, inboxId, limit);
14084
+ } catch (err) {
14085
+ app.log.error({
14086
+ err,
14087
+ agentId,
14088
+ inboxId,
14089
+ limit
14090
+ }, "claimBacklogForPush failed");
14091
+ return;
14092
+ }
14093
+ for (const entry of entries) {
14094
+ inboxInFlight.set(agentId, (inboxInFlight.get(agentId) ?? 0) + 1);
14095
+ if (!sendInboxDeliverFrame(entry)) {
14096
+ inboxInFlight.set(agentId, Math.max(0, (inboxInFlight.get(agentId) ?? 1) - 1));
14097
+ return;
14098
+ }
14099
+ }
14100
+ }
12597
14101
  const sessionOpQueues = /* @__PURE__ */ new Map();
12598
14102
  function chainSessionOp(agentId, chatId, op) {
12599
14103
  const key = `${agentId}:${chatId}`;
@@ -12698,7 +14202,8 @@ function clientWsRoutes(notifier, instanceId) {
12698
14202
  socket.send(JSON.stringify({
12699
14203
  type: "server:welcome",
12700
14204
  serverCommandVersion: app.commandVersion,
12701
- serverTimeMs: Date.now()
14205
+ serverTimeMs: Date.now(),
14206
+ capabilities: { wsInboxDeliver: true }
12702
14207
  }));
12703
14208
  } catch (err) {
12704
14209
  const message = err instanceof Error ? err.message : "auth failure";
@@ -12715,6 +14220,7 @@ function clientWsRoutes(notifier, instanceId) {
12715
14220
  try {
12716
14221
  if (type === "client:register") {
12717
14222
  const data = clientRegisterSchema.parse(msg);
14223
+ clientWantsWsInboxDeliver = data.wireCapabilities?.wsInboxDeliver === true;
12718
14224
  try {
12719
14225
  await registerClient(app.db, {
12720
14226
  clientId: data.clientId,
@@ -12751,7 +14257,8 @@ function clientWsRoutes(notifier, instanceId) {
12751
14257
  agentId: agent.uuid,
12752
14258
  name: agent.name,
12753
14259
  displayName: agent.displayName,
12754
- agentType: agent.type
14260
+ agentType: agent.type,
14261
+ runtimeProvider: agent.runtimeProvider
12755
14262
  });
12756
14263
  if (!parsed.success) {
12757
14264
  app.log.warn({
@@ -12787,6 +14294,7 @@ function clientWsRoutes(notifier, instanceId) {
12787
14294
  inboxId: agents.inboxId,
12788
14295
  status: agents.status,
12789
14296
  clientId: agents.clientId,
14297
+ runtimeProvider: agents.runtimeProvider,
12790
14298
  clientUserId: clients.userId,
12791
14299
  managerUserId: members.userId
12792
14300
  }).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 +14329,10 @@ function clientWsRoutes(notifier, instanceId) {
12821
14329
  sendRejected(socket, ref, AGENT_BIND_REJECT_REASONS.NOT_OWNED);
12822
14330
  return;
12823
14331
  }
14332
+ if (bindRequest.runtimeType !== agent.runtimeProvider) {
14333
+ sendRejected(socket, ref, AGENT_BIND_REJECT_REASONS.RUNTIME_PROVIDER_MISMATCH);
14334
+ return;
14335
+ }
12824
14336
  await bindAgent(app.db, agent.id, {
12825
14337
  clientId,
12826
14338
  instanceId,
@@ -12832,7 +14344,9 @@ function clientWsRoutes(notifier, instanceId) {
12832
14344
  agentId: agent.id,
12833
14345
  inboxId: agent.inboxId
12834
14346
  });
12835
- notifier.subscribe(agent.inboxId, socket);
14347
+ const wsPushActive = pushUseWsDataPlane();
14348
+ if (wsPushActive) notifier.subscribe(agent.inboxId, socket, makeInboxPushHandler(agent.id, agent.inboxId));
14349
+ else notifier.subscribe(agent.inboxId, socket);
12836
14350
  socket.send(JSON.stringify({
12837
14351
  type: "agent:bound",
12838
14352
  ref,
@@ -12840,6 +14354,12 @@ function clientWsRoutes(notifier, instanceId) {
12840
14354
  displayName: agent.displayName,
12841
14355
  agentType: agent.type
12842
14356
  }));
14357
+ if (wsPushActive) drainBacklogForAgent(agent.id, agent.inboxId).catch((err) => {
14358
+ app.log.error({
14359
+ err,
14360
+ agentId: agent.id
14361
+ }, "post-bind backlog drain crashed");
14362
+ });
12843
14363
  } else if (type === "agent:unbind") {
12844
14364
  const agentId = parsed.data.agentId;
12845
14365
  if (!agentId || !boundAgents.has(agentId)) {
@@ -12854,6 +14374,7 @@ function clientWsRoutes(notifier, instanceId) {
12854
14374
  await unbindAgent(app.db, agentId);
12855
14375
  unbindAgentFromClient(agentId);
12856
14376
  boundAgents.delete(agentId);
14377
+ inboxInFlight.delete(agentId);
12857
14378
  socket.send(JSON.stringify({
12858
14379
  type: "agent:unbound",
12859
14380
  agentId
@@ -12955,6 +14476,35 @@ function clientWsRoutes(notifier, instanceId) {
12955
14476
  }
12956
14477
  const payload = sessionCompletionMessageSchema.parse(msg);
12957
14478
  if (shouldNotify(agentId, `session_completed:${payload.chatId}`)) notifyAgentEvent(app.db, agentId, "session_completed", "low", payload.chatId).catch(() => {});
14479
+ } else if (type === "inbox:ack") {
14480
+ const payloadResult = inboxAckFrameSchema.safeParse(msg);
14481
+ if (!payloadResult.success) {
14482
+ socket.send(JSON.stringify({
14483
+ type: "error",
14484
+ message: "Malformed inbox:ack frame"
14485
+ }));
14486
+ return;
14487
+ }
14488
+ const { entryId } = payloadResult.data;
14489
+ try {
14490
+ const ackedEntry = await ackEntryByIdForBoundAgents(app.db, entryId, [...boundAgents.values()].map((a) => a.inboxId));
14491
+ if (!ackedEntry) return;
14492
+ const owner = [...boundAgents.values()].find((a) => a.inboxId === ackedEntry.inboxId);
14493
+ if (owner) {
14494
+ inboxInFlight.set(owner.agentId, Math.max(0, (inboxInFlight.get(owner.agentId) ?? 1) - 1));
14495
+ drainBacklogForAgent(owner.agentId, owner.inboxId).catch((err) => {
14496
+ app.log.error({
14497
+ err,
14498
+ agentId: owner.agentId
14499
+ }, "post-ack backlog drain crashed");
14500
+ });
14501
+ }
14502
+ } catch (err) {
14503
+ app.log.error({
14504
+ err,
14505
+ entryId
14506
+ }, "inbox:ack handling failed");
14507
+ }
12958
14508
  } else if (type === "heartbeat") {
12959
14509
  if (clientId) {
12960
14510
  await heartbeatClient(app.db, clientId);
@@ -13169,7 +14719,8 @@ const authIdentities = pgTable("auth_identities", {
13169
14719
  }, (table) => [
13170
14720
  unique("uq_auth_identities_provider_identifier").on(table.provider, table.identifier),
13171
14721
  index("idx_auth_identities_user").on(table.userId),
13172
- index("idx_auth_identities_email").on(table.email)
14722
+ index("idx_auth_identities_email").on(table.email),
14723
+ uniqueIndex("uq_auth_identities_user_github").on(table.userId).where(sql`provider = 'github'`)
13173
14724
  ]);
13174
14725
  /**
13175
14726
  * Find or create the user backing a GitHub OAuth identity. Idempotent —
@@ -13342,19 +14893,20 @@ function sanitizeAgentName(login) {
13342
14893
  return login.toLowerCase().replace(/[^a-z0-9_-]/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "user";
13343
14894
  }
13344
14895
  /**
13345
- * Create a fresh "personal team" org for a brand-new user, plus the
13346
- * matching admin membership + 1:1 human agent. Slug strategy:
14896
+ * Create a fresh default team org for a brand-new user, plus the matching
14897
+ * admin membership + 1:1 human agent. Slug strategy:
13347
14898
  *
13348
- * - First try: `${login}-personal`
14899
+ * - First try: `${login}` (lowercased, sanitized)
13349
14900
  * - On collision: append a 4-char hex disambiguator
13350
14901
  *
13351
- * The display name is `{user}'s Personal Team` so it reads sensibly in the
13352
- * UI; the user can rename via Settings later (proposal §"Personal team
13353
- * visual降级").
14902
+ * Display name is the user's GitHub real name (or login as fallback). No
14903
+ * "Personal Team" suffix — the user might invite teammates later, and we
14904
+ * don't want a label that reads like a private sandbox to be the team name
14905
+ * other members see. Users rename freely via Settings.
13354
14906
  */
13355
14907
  async function createPersonalTeam(db, input) {
13356
- const baseSlug = sanitizeOrgSlug(`${input.loginSeed}-personal`);
13357
- const displayName = `${input.userDisplayName}'s Personal Team`;
14908
+ const baseSlug = sanitizeOrgSlug(input.loginSeed);
14909
+ const displayName = input.userDisplayName;
13358
14910
  const orgId = uuidv7();
13359
14911
  return {
13360
14912
  organizationId: orgId,
@@ -13640,9 +15192,7 @@ async function githubOauthRoutes(app) {
13640
15192
  return completeOauthFlow(app, request, reply, profile, next);
13641
15193
  });
13642
15194
  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" });
15195
+ if (process.env.NODE_ENV === "production") return reply.status(404).send({ error: "Not found" });
13646
15196
  const params = githubDevCallbackQuerySchema.parse(request.query);
13647
15197
  const next = safeRedirectPath(params.next ?? null);
13648
15198
  return completeOauthFlow(app, request, reply, {
@@ -14046,7 +15596,7 @@ async function inferWizardStep(app, m) {
14046
15596
  * landing page.
14047
15597
  */
14048
15598
  async function publicInvitePreviewRoute(app) {
14049
- const { previewInvitation } = await import("./invitation-BTlGMy0o-dIoR8JRj.mjs");
15599
+ const { previewInvitation } = await import("./invitation-D3feYxet-366MNOor.mjs");
14050
15600
  app.get("/:token/preview", async (request, reply) => {
14051
15601
  if (!request.params.token) throw new UnauthorizedError("Token required");
14052
15602
  const preview = await previewInvitation(app.db, request.params.token);
@@ -14076,7 +15626,7 @@ async function adminInvitationRoutes(app) {
14076
15626
  const m = requireMember(request);
14077
15627
  if (m.role !== "admin") throw new ForbiddenError("Admin role required");
14078
15628
  if (request.params.id !== m.organizationId) throw new ForbiddenError("Cannot rotate invitations for another organization");
14079
- const { rotateInvitation } = await import("./invitation-BTlGMy0o-dIoR8JRj.mjs");
15629
+ const { rotateInvitation } = await import("./invitation-D3feYxet-366MNOor.mjs");
14080
15630
  const inv = await rotateInvitation(app.db, m.organizationId, m.userId);
14081
15631
  return {
14082
15632
  id: inv.id,
@@ -15443,6 +16993,7 @@ function createConfigService(opts) {
15443
16993
  */
15444
16994
  function applyPatch(current, patch) {
15445
16995
  return {
16996
+ kind: current.kind,
15446
16997
  prompt: patch.prompt ?? current.prompt,
15447
16998
  model: patch.model ?? current.model,
15448
16999
  mcpServers: patch.mcpServers ?? current.mcpServers,
@@ -15468,13 +17019,26 @@ function createConfigService(opts) {
15468
17019
  async function readRow(agentId) {
15469
17020
  const [row] = await db.select().from(agentConfigs).where(eq(agentConfigs.agentId, agentId)).limit(1);
15470
17021
  if (!row) throw new NotFoundError(`Agent config "${agentId}" not found`);
15471
- return row;
17022
+ const payload = agentRuntimeConfigPayloadSchema$1.parse(row.payload);
17023
+ return {
17024
+ ...row,
17025
+ payload
17026
+ };
17027
+ }
17028
+ async function readRuntimeProviderFor(agentId) {
17029
+ const [row] = await db.select({ runtimeProvider: agents.runtimeProvider }).from(agents).where(eq(agents.uuid, agentId)).limit(1);
17030
+ if (!row) throw new NotFoundError(`Agent "${agentId}" not found`);
17031
+ return row.runtimeProvider;
15472
17032
  }
15473
17033
  async function commitWrite(agentId, patch, expectedVersion, updatedBy) {
15474
17034
  const current = await readRow(agentId);
15475
17035
  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);
17036
+ const provider = await readRuntimeProviderFor(agentId);
17037
+ const synced = {
17038
+ ...applyPatch(current.payload, patch),
17039
+ kind: provider
17040
+ };
17041
+ const validated = agentRuntimeConfigPayloadSchema$1.parse(synced);
15478
17042
  const [updated] = await db.update(agentConfigs).set({
15479
17043
  version: sql`${agentConfigs.version} + 1`,
15480
17044
  payload: validated,
@@ -15581,7 +17145,12 @@ function createConfigService(opts) {
15581
17145
  },
15582
17146
  async dryRun(agentId, patch) {
15583
17147
  const row = await readRow(agentId);
15584
- const next = agentRuntimeConfigPayloadSchema$1.parse(applyPatch(row.payload, patch));
17148
+ const provider = await readRuntimeProviderFor(agentId);
17149
+ const synced = {
17150
+ ...applyPatch(row.payload, patch),
17151
+ kind: provider
17152
+ };
17153
+ const next = agentRuntimeConfigPayloadSchema$1.parse(synced);
15585
17154
  const diff = computeDiff(row.payload, next);
15586
17155
  return {
15587
17156
  current: {
@@ -16672,4 +18241,4 @@ function registerSaaSConnectCommand(program) {
16672
18241
  });
16673
18242
  }
16674
18243
  //#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 };
18244
+ 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 };